@prairielearn/ui 1.2.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.
- package/CHANGELOG.md +32 -0
- package/README.md +4 -2
- package/dist/components/CategoricalColumnFilter.d.ts +7 -12
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js +26 -14
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +6 -2
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +98 -35
- package/dist/components/ColumnManager.js.map +1 -1
- package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
- package/dist/components/MultiSelectColumnFilter.js +21 -13
- package/dist/components/MultiSelectColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.d.ts +13 -13
- package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
- package/dist/components/NumericInputColumnFilter.js +44 -15
- package/dist/components/NumericInputColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
- package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
- package/dist/components/NumericInputColumnFilter.test.js +90 -0
- package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
- package/dist/components/OverlayTrigger.d.ts +78 -0
- package/dist/components/OverlayTrigger.d.ts.map +1 -0
- package/dist/components/OverlayTrigger.js +89 -0
- package/dist/components/OverlayTrigger.js.map +1 -0
- package/dist/components/TanstackTable.d.ts +19 -3
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +159 -219
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +4 -3
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
- package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
- package/dist/components/TanstackTableHeaderCell.js +98 -0
- package/dist/components/TanstackTableHeaderCell.js.map +1 -0
- package/dist/components/styles.css +58 -0
- package/dist/components/useAutoSizeColumns.d.ts +17 -0
- package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
- package/dist/components/useAutoSizeColumns.js +99 -0
- package/dist/components/useAutoSizeColumns.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/react-table.d.ts +13 -0
- package/dist/react-table.d.ts.map +1 -0
- package/dist/react-table.js +3 -0
- package/dist/react-table.js.map +1 -0
- package/package.json +2 -2
- package/src/components/CategoricalColumnFilter.tsx +84 -54
- package/src/components/ColumnManager.tsx +236 -88
- package/src/components/MultiSelectColumnFilter.tsx +45 -32
- package/src/components/NumericInputColumnFilter.test.ts +67 -19
- package/src/components/NumericInputColumnFilter.tsx +102 -42
- package/src/components/OverlayTrigger.tsx +168 -0
- package/src/components/TanstackTable.tsx +357 -410
- package/src/components/TanstackTableDownloadButton.tsx +8 -5
- package/src/components/TanstackTableHeaderCell.tsx +207 -0
- package/src/components/styles.css +58 -0
- package/src/components/useAutoSizeColumns.tsx +168 -0
- package/src/index.ts +10 -1
- package/src/react-table.ts +17 -0
- package/tsconfig.json +1 -2
- package/dist/components/TanstackTable.css +0 -4
- package/src/components/TanstackTable.css +0 -4
|
@@ -10,15 +10,18 @@ function defaultRenderValueLabel({ value }) {
|
|
|
10
10
|
* Uses AND logic: rows must contain ALL selected values to match.
|
|
11
11
|
*
|
|
12
12
|
* @param params
|
|
13
|
-
* @param params.
|
|
14
|
-
* @param params.
|
|
15
|
-
* @param params.allColumnValues - All possible values that can appear in the column
|
|
13
|
+
* @param params.column - The TanStack Table column object
|
|
14
|
+
* @param params.allColumnValues - All possible values that can appear in the column filter
|
|
16
15
|
* @param params.renderValueLabel - A function that renders the label for a value
|
|
17
|
-
* @param params.columnValuesFilter - The current state of the column filter
|
|
18
|
-
* @param params.setColumnValuesFilter - A function that sets the state of the column filter
|
|
19
16
|
*/
|
|
20
|
-
export function MultiSelectColumnFilter({
|
|
21
|
-
const
|
|
17
|
+
export function MultiSelectColumnFilter({ column, allColumnValues, renderValueLabel = defaultRenderValueLabel, }) {
|
|
18
|
+
const columnId = column.id;
|
|
19
|
+
const label = column.columnDef.meta?.label ??
|
|
20
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
21
|
+
const columnValuesFilter = column.getFilterValue();
|
|
22
|
+
const selected = useMemo(() => {
|
|
23
|
+
return new Set(columnValuesFilter);
|
|
24
|
+
}, [columnValuesFilter]);
|
|
22
25
|
const toggleSelected = (value) => {
|
|
23
26
|
const set = new Set(selected);
|
|
24
27
|
if (set.has(value)) {
|
|
@@ -27,15 +30,20 @@ export function MultiSelectColumnFilter({ columnId, columnLabel, allColumnValues
|
|
|
27
30
|
else {
|
|
28
31
|
set.add(value);
|
|
29
32
|
}
|
|
30
|
-
|
|
33
|
+
const newValue = Array.from(set);
|
|
34
|
+
column.setFilterValue(newValue);
|
|
31
35
|
};
|
|
32
36
|
const hasActiveFilter = selected.size > 0;
|
|
33
|
-
return (_jsxs(Dropdown, { align: "end", children: [_jsx(Dropdown.Toggle, { variant: "link", class: "text-muted p-0", id: `filter-${columnId}`, "aria-label": `Filter ${
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
return (_jsxs(Dropdown, { align: "end", children: [_jsx(Dropdown.Toggle, { variant: "link", class: "text-muted p-0", id: `filter-${columnId}`, "aria-label": `Filter ${label.toLowerCase()}`, title: `Filter ${label.toLowerCase()}`, children: _jsx("i", { class: clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel'), "aria-hidden": "true" }) }), _jsxs(Dropdown.Menu, { class: "p-0", children: [_jsx("div", { class: "p-3 pb-0", style: { minWidth: '250px' }, children: _jsxs("div", { class: "d-flex align-items-center justify-content-between mb-2", children: [_jsx("div", { class: "fw-semibold", children: label }), _jsx("button", { type: "button", class: "btn btn-link btn-sm text-decoration-none p-0", onClick: () => column.setFilterValue([]), children: "Clear" })] }) }), _jsx("div", { class: "list-group list-group-flush", style: {
|
|
38
|
+
// This is needed to prevent the last item's background from covering
|
|
39
|
+
// the dropdown's border radius.
|
|
40
|
+
'--bs-list-group-bg': 'transparent',
|
|
41
|
+
}, children: allColumnValues.map((value) => {
|
|
42
|
+
const isSelected = selected.has(value);
|
|
43
|
+
return (_jsx("div", { class: "list-group-item d-flex align-items-center gap-3", children: _jsxs("div", { class: "form-check", children: [_jsx("input", { class: "form-check-input", type: "checkbox", checked: isSelected, id: `${columnId}-${value}`, onChange: () => toggleSelected(value) }), _jsx("label", { class: "form-check-label fw-normal", for: `${columnId}-${value}`, children: renderValueLabel({
|
|
36
44
|
value,
|
|
37
45
|
isSelected,
|
|
38
|
-
}) })] }, value));
|
|
39
|
-
|
|
46
|
+
}) })] }) }, value));
|
|
47
|
+
}) })] })] }));
|
|
40
48
|
}
|
|
41
49
|
//# sourceMappingURL=MultiSelectColumnFilter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiSelectColumnFilter.js","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"MultiSelectColumnFilter.js","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { Column } from '@tanstack/table-core';\nimport clsx from 'clsx';\nimport { type JSX, useMemo } from 'preact/compat';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction defaultRenderValueLabel<T>({ value }: { value: T }) {\n return <span>{String(value)}</span>;\n}\n\n/**\n * A component that allows the user to filter a column containing arrays of values.\n * Uses AND logic: rows must contain ALL selected values to match.\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - All possible values that can appear in the column filter\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function MultiSelectColumnFilter<TData, TValue>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, TValue>;\n /** In some cases, the filter values are not the same as the column values, but `TValue` is a good estimation. */\n allColumnValues: TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => JSX.Element;\n}) {\n const columnId = column.id;\n\n const label =\n column.columnDef.meta?.label ??\n (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);\n\n const columnValuesFilter = column.getFilterValue() as TValue[] | undefined;\n\n const selected = useMemo(() => {\n return new Set(columnValuesFilter);\n }, [columnValuesFilter]);\n\n const toggleSelected = (value: TValue) => {\n const set = new Set(selected);\n if (set.has(value)) {\n set.delete(value);\n } else {\n set.add(value);\n }\n const newValue = Array.from(set);\n column.setFilterValue(newValue);\n };\n\n const hasActiveFilter = selected.size > 0;\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n class=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n class={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu class=\"p-0\">\n <div class=\"p-3 pb-0\" style={{ minWidth: '250px' }}>\n <div class=\"d-flex align-items-center justify-content-between mb-2\">\n <div class=\"fw-semibold\">{label}</div>\n <button\n type=\"button\"\n class=\"btn btn-link btn-sm text-decoration-none p-0\"\n onClick={() => column.setFilterValue([])}\n >\n Clear\n </button>\n </div>\n </div>\n\n <div\n class=\"list-group list-group-flush\"\n style={{\n // This is needed to prevent the last item's background from covering\n // the dropdown's border radius.\n '--bs-list-group-bg': 'transparent',\n }}\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} class=\"list-group-item d-flex align-items-center gap-3\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label class=\"form-check-label fw-normal\" for={`${columnId}-${value}`}>\n {renderValueLabel({\n value,\n isSelected,\n })}\n </label>\n </div>\n </div>\n );\n })}\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n"]}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { Column } from '@tanstack/table-core';
|
|
2
|
+
export type NumericColumnFilterValue = {
|
|
3
|
+
filterValue: string;
|
|
4
|
+
emptyOnly: false;
|
|
5
|
+
} | {
|
|
6
|
+
filterValue: '';
|
|
7
|
+
emptyOnly: true;
|
|
8
|
+
};
|
|
7
9
|
/**
|
|
8
10
|
* A component that allows the user to filter a numeric column using comparison operators.
|
|
9
11
|
* Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
|
|
10
12
|
*
|
|
11
13
|
* @param params
|
|
12
|
-
* @param params.
|
|
13
|
-
* @param params.columnLabel - The label of the column, e.g. "Manual Points"
|
|
14
|
-
* @param params.value - The current filter value (e.g., ">5" or "10")
|
|
15
|
-
* @param params.onChange - Callback when the filter value changes
|
|
14
|
+
* @param params.column - The TanStack Table column object
|
|
16
15
|
*/
|
|
17
|
-
export declare function NumericInputColumnFilter({
|
|
16
|
+
export declare function NumericInputColumnFilter<TData, TValue>({ column, }: {
|
|
17
|
+
column: Column<TData, TValue>;
|
|
18
|
+
}): import("original-preact").JSX.Element;
|
|
18
19
|
/**
|
|
19
20
|
* Helper function to parse a numeric filter value.
|
|
20
21
|
* Returns null if the filter is invalid or empty.
|
|
@@ -37,6 +38,5 @@ export declare function parseNumericFilter(filterValue: string): {
|
|
|
37
38
|
* filterFn: numericColumnFilterFn,
|
|
38
39
|
* }
|
|
39
40
|
*/
|
|
40
|
-
export declare function numericColumnFilterFn(row: any, columnId: string, filterValue:
|
|
41
|
-
export {};
|
|
41
|
+
export declare function numericColumnFilterFn(row: any, columnId: string, { filterValue, emptyOnly }: NumericColumnFilterValue): boolean;
|
|
42
42
|
//# sourceMappingURL=NumericInputColumnFilter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NumericInputColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"NumericInputColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAInD,MAAM,MAAM,wBAAwB,GAChC;IACE,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,KAAK,CAAC;CAClB,GACD;IACE,WAAW,EAAE,EAAE,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB,CAAC;AAEN;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,EACtD,MAAM,GACP,EAAE;IACD,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;CAC/B,yCAmHA;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG;IACvD,QAAQ,EAAE,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,GAAG,GAAG,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,IAAI,CAYP;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,MAAM,EAChB,EAAE,WAAW,EAAE,SAAS,EAAE,EAAE,wBAAwB,GACnD,OAAO,CA+BT"}
|
|
@@ -6,23 +6,44 @@ import Dropdown from 'react-bootstrap/Dropdown';
|
|
|
6
6
|
* Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
|
|
7
7
|
*
|
|
8
8
|
* @param params
|
|
9
|
-
* @param params.
|
|
10
|
-
* @param params.columnLabel - The label of the column, e.g. "Manual Points"
|
|
11
|
-
* @param params.value - The current filter value (e.g., ">5" or "10")
|
|
12
|
-
* @param params.onChange - Callback when the filter value changes
|
|
9
|
+
* @param params.column - The TanStack Table column object
|
|
13
10
|
*/
|
|
14
|
-
export function NumericInputColumnFilter({
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
11
|
+
export function NumericInputColumnFilter({ column, }) {
|
|
12
|
+
const columnId = column.id;
|
|
13
|
+
const value = column.getFilterValue() ?? {
|
|
14
|
+
filterValue: '',
|
|
15
|
+
emptyOnly: false,
|
|
16
|
+
};
|
|
17
|
+
const label = column.columnDef.meta?.label ??
|
|
18
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
19
|
+
const filterValue = value.filterValue;
|
|
20
|
+
const emptyOnly = value.emptyOnly;
|
|
21
|
+
const hasActiveFilter = filterValue.trim().length > 0 || emptyOnly;
|
|
22
|
+
const isInvalid = filterValue.trim().length > 0 && parseNumericFilter(filterValue) === null;
|
|
23
|
+
return (_jsxs(Dropdown, { align: "end", children: [_jsx(Dropdown.Toggle, { variant: "link", class: clsx('text-muted p-0', hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary')), id: `filter-${columnId}`, "aria-label": `Filter ${label.toLowerCase()}`, title: `Filter ${label.toLowerCase()}`, children: _jsx("i", { class: clsx('bi', isInvalid
|
|
18
24
|
? 'bi-exclamation-triangle'
|
|
19
25
|
: hasActiveFilter
|
|
20
26
|
? 'bi-funnel-fill'
|
|
21
|
-
: 'bi-funnel'), "aria-hidden": "true" }) }), _jsx(Dropdown.Menu
|
|
27
|
+
: 'bi-funnel'), "aria-hidden": "true" }) }), _jsx(Dropdown.Menu
|
|
28
|
+
// eslint-disable-next-line @eslint-react/no-forbidden-props
|
|
29
|
+
, {
|
|
30
|
+
// eslint-disable-next-line @eslint-react/no-forbidden-props
|
|
31
|
+
className: "p-0", children: _jsxs("div", { class: "p-3", style: { minWidth: '240px' }, children: [_jsxs("div", { class: "d-flex align-items-center justify-content-between mb-2", children: [_jsx("label", { class: "form-label fw-semibold mb-0", id: `${columnId}-filter-label`, children: label }), _jsx("button", { type: "button", class: clsx('btn btn-link btn-sm text-decoration-none', !hasActiveFilter && 'invisible'), onClick: () => {
|
|
32
|
+
column.setFilterValue({ filterValue: '', emptyOnly: false });
|
|
33
|
+
}, children: "Clear" })] }), _jsx("input", { type: "text", class: clsx('form-control form-control-sm', isInvalid && 'is-invalid'), placeholder: "e.g., >0, <5, =10", "aria-labelledby": `${columnId}-filter-label`, value: filterValue, disabled: emptyOnly, "aria-describedby": `${columnId}-filter-description`, onInput: (e) => {
|
|
22
34
|
if (e.target instanceof HTMLInputElement) {
|
|
23
|
-
|
|
35
|
+
column.setFilterValue({
|
|
36
|
+
filterValue: e.target.value,
|
|
37
|
+
emptyOnly: false,
|
|
38
|
+
});
|
|
24
39
|
}
|
|
25
|
-
}, onClick: (e) => e.stopPropagation() }), isInvalid && (_jsxs("div", { class: "invalid-feedback d-block", children: ["Invalid filter format. Use operators like ", _jsx("code", { children: ">5" }), " or ", _jsx("code", { children: "<=10" })] })), !isInvalid && (_jsxs("
|
|
40
|
+
}, onClick: (e) => e.stopPropagation() }), isInvalid && (_jsxs("div", { class: "invalid-feedback d-block", children: ["Invalid filter format. Use operators like ", _jsx("code", { children: ">5" }), " or ", _jsx("code", { children: "<=10" })] })), !isInvalid && (_jsxs("small", { class: "form-text text-nowrap", id: `${columnId}-filter-description`, children: ["Operators: ", _jsx("code", { children: "<" }), ", ", _jsx("code", { children: ">" }), ", ", _jsx("code", { children: "<=" }), ",", ' ', _jsx("code", { children: ">=" }), ", ", _jsx("code", { children: "=" })] })), _jsxs("div", { class: "form-check mt-2", children: [_jsx("input", { class: "form-check-input", type: "checkbox", checked: emptyOnly, id: `${columnId}-empty-filter`, onChange: (e) => {
|
|
41
|
+
if (e.target instanceof HTMLInputElement) {
|
|
42
|
+
column.setFilterValue(e.target.checked
|
|
43
|
+
? { filterValue: '', emptyOnly: true }
|
|
44
|
+
: { filterValue: '', emptyOnly: false });
|
|
45
|
+
}
|
|
46
|
+
} }), _jsx("label", { class: "form-check-label", for: `${columnId}-empty-filter`, children: "Empty values" })] })] }) })] }));
|
|
26
47
|
}
|
|
27
48
|
/**
|
|
28
49
|
* Helper function to parse a numeric filter value.
|
|
@@ -54,13 +75,21 @@ export function parseNumericFilter(filterValue) {
|
|
|
54
75
|
* filterFn: numericColumnFilterFn,
|
|
55
76
|
* }
|
|
56
77
|
*/
|
|
57
|
-
export function numericColumnFilterFn(row, columnId, filterValue) {
|
|
78
|
+
export function numericColumnFilterFn(row, columnId, { filterValue, emptyOnly }) {
|
|
79
|
+
// Handle object-based filter value
|
|
80
|
+
const cellValue = row.getValue(columnId);
|
|
81
|
+
const isEmpty = cellValue == null;
|
|
82
|
+
if (emptyOnly) {
|
|
83
|
+
return isEmpty;
|
|
84
|
+
}
|
|
85
|
+
// If there's no numeric filter, show all rows
|
|
58
86
|
const parsed = parseNumericFilter(filterValue);
|
|
59
87
|
if (!parsed)
|
|
60
|
-
return true;
|
|
61
|
-
|
|
62
|
-
if (
|
|
88
|
+
return true;
|
|
89
|
+
// If cell is empty and we're doing numeric filtering, don't show it
|
|
90
|
+
if (isEmpty)
|
|
63
91
|
return false;
|
|
92
|
+
// Apply numeric filter
|
|
64
93
|
switch (parsed.operator) {
|
|
65
94
|
case '<':
|
|
66
95
|
return cellValue < parsed.value;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NumericInputColumnFilter.js","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\ninterface NumericInputColumnFilterProps {\n columnId: string;\n columnLabel: string;\n value: string;\n onChange: (value: string) => void;\n}\n\n/**\n * A component that allows the user to filter a numeric column using comparison operators.\n * Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)\n *\n * @param params\n * @param params.columnId - The ID of the column\n * @param params.columnLabel - The label of the column, e.g. \"Manual Points\"\n * @param params.value - The current filter value (e.g., \">5\" or \"10\")\n * @param params.onChange - Callback when the filter value changes\n */\nexport function NumericInputColumnFilter({\n columnId,\n columnLabel,\n value,\n onChange,\n}: NumericInputColumnFilterProps) {\n const hasActiveFilter = value.trim().length > 0;\n const isInvalid = hasActiveFilter && parseNumericFilter(value) === null;\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n class={clsx(\n 'text-muted p-0',\n hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),\n )}\n id={`filter-${columnId}`}\n aria-label={`Filter ${columnLabel.toLowerCase()}`}\n title={`Filter ${columnLabel.toLowerCase()}`}\n >\n <i\n class={clsx(\n 'bi',\n isInvalid\n ? 'bi-exclamation-triangle'\n : hasActiveFilter\n ? 'bi-funnel-fill'\n : 'bi-funnel',\n )}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu>\n <div class=\"p-3\" style={{ minWidth: '240px' }}>\n <label class=\"form-label small fw-semibold mb-2\">{columnLabel}</label>\n <input\n type=\"text\"\n class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}\n placeholder=\"e.g., >0, <5, =10\"\n value={value}\n onInput={(e) => {\n if (e.target instanceof HTMLInputElement) {\n onChange(e.target.value);\n }\n }}\n onClick={(e) => e.stopPropagation()}\n />\n {isInvalid && (\n <div class=\"invalid-feedback d-block\">\n Invalid filter format. Use operators like <code>>5</code> or <code><=10</code>\n </div>\n )}\n {!isInvalid && (\n <div class=\"form-text small mt-2\">\n Use operators: <code><</code>, <code>></code>, <code><=</code>,{' '}\n <code>>=</code>, <code>=</code>\n <br />\n Example: <code>>5</code> or <code><=10</code>\n </div>\n )}\n {hasActiveFilter && (\n <button\n type=\"button\"\n class=\"btn btn-sm btn-link text-decoration-none mt-2 p-0\"\n onClick={() => onChange('')}\n >\n Clear filter\n </button>\n )}\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n\n/**\n * Helper function to parse a numeric filter value.\n * Returns null if the filter is invalid or empty.\n *\n * @param filterValue - The filter string (e.g., \">5\", \"<=10\", \"3\")\n * @returns Parsed operator and value, or null if invalid\n */\nexport function parseNumericFilter(filterValue: string): {\n operator: '<' | '>' | '<=' | '>=' | '=';\n value: number;\n} | null {\n if (!filterValue.trim()) return null;\n\n const match = filterValue.trim().match(/^(<=?|>=?|=)?\\s*(-?\\d+\\.?\\d*)$/);\n if (!match) return null;\n\n const operator = (match[1] || '=') as '<' | '>' | '<=' | '>=' | '=';\n const value = Number.parseFloat(match[2]);\n\n if (Number.isNaN(value)) return null;\n\n return { operator, value };\n}\n\n/**\n * TanStack Table filter function for numeric columns.\n * Use this as the `filterFn` for numeric columns.\n *\n * @example\n * {\n * id: 'manual_points',\n * accessorKey: 'manual_points',\n * filterFn: numericColumnFilterFn,\n * }\n */\nexport function numericColumnFilterFn(row: any, columnId: string, filterValue: string): boolean {\n const parsed = parseNumericFilter(filterValue);\n if (!parsed) return true; // Invalid or empty filter = show all\n\n const cellValue = row.getValue(columnId) as number | null;\n if (cellValue === null || cellValue === undefined) return false;\n\n switch (parsed.operator) {\n case '<':\n return cellValue < parsed.value;\n case '>':\n return cellValue > parsed.value;\n case '<=':\n return cellValue <= parsed.value;\n case '>=':\n return cellValue >= parsed.value;\n case '=':\n return cellValue === parsed.value;\n default:\n return true;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"NumericInputColumnFilter.js","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { Column } from '@tanstack/table-core';\nimport clsx from 'clsx';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nexport type NumericColumnFilterValue =\n | {\n filterValue: string;\n emptyOnly: false;\n }\n | {\n filterValue: '';\n emptyOnly: true;\n };\n\n/**\n * A component that allows the user to filter a numeric column using comparison operators.\n * Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)\n *\n * @param params\n * @param params.column - The TanStack Table column object\n */\nexport function NumericInputColumnFilter<TData, TValue>({\n column,\n}: {\n column: Column<TData, TValue>;\n}) {\n const columnId = column.id;\n const value = (column.getFilterValue() as NumericColumnFilterValue | undefined) ?? {\n filterValue: '',\n emptyOnly: false,\n };\n\n const label =\n column.columnDef.meta?.label ??\n (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);\n\n const filterValue = value.filterValue;\n const emptyOnly = value.emptyOnly;\n const hasActiveFilter = filterValue.trim().length > 0 || emptyOnly;\n const isInvalid = filterValue.trim().length > 0 && parseNumericFilter(filterValue) === null;\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n class={clsx(\n 'text-muted p-0',\n hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),\n )}\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n class={clsx(\n 'bi',\n isInvalid\n ? 'bi-exclamation-triangle'\n : hasActiveFilter\n ? 'bi-funnel-fill'\n : 'bi-funnel',\n )}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu\n // eslint-disable-next-line @eslint-react/no-forbidden-props\n className=\"p-0\"\n >\n <div class=\"p-3\" style={{ minWidth: '240px' }}>\n <div class=\"d-flex align-items-center justify-content-between mb-2\">\n <label class=\"form-label fw-semibold mb-0\" id={`${columnId}-filter-label`}>\n {label}\n </label>\n <button\n type=\"button\"\n class={clsx(\n 'btn btn-link btn-sm text-decoration-none',\n !hasActiveFilter && 'invisible',\n )}\n onClick={() => {\n column.setFilterValue({ filterValue: '', emptyOnly: false });\n }}\n >\n Clear\n </button>\n </div>\n <input\n type=\"text\"\n class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}\n placeholder=\"e.g., >0, <5, =10\"\n aria-labelledby={`${columnId}-filter-label`}\n value={filterValue}\n disabled={emptyOnly}\n aria-describedby={`${columnId}-filter-description`}\n onInput={(e) => {\n if (e.target instanceof HTMLInputElement) {\n column.setFilterValue({\n filterValue: e.target.value,\n emptyOnly: false,\n });\n }\n }}\n onClick={(e) => e.stopPropagation()}\n />\n {isInvalid && (\n <div class=\"invalid-feedback d-block\">\n Invalid filter format. Use operators like <code>>5</code> or <code><=10</code>\n </div>\n )}\n {!isInvalid && (\n <small class=\"form-text text-nowrap\" id={`${columnId}-filter-description`}>\n Operators: <code><</code>, <code>></code>, <code><=</code>,{' '}\n <code>>=</code>, <code>=</code>\n </small>\n )}\n <div class=\"form-check mt-2\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n checked={emptyOnly}\n id={`${columnId}-empty-filter`}\n onChange={(e) => {\n if (e.target instanceof HTMLInputElement) {\n column.setFilterValue(\n e.target.checked\n ? { filterValue: '', emptyOnly: true }\n : { filterValue: '', emptyOnly: false },\n );\n }\n }}\n />\n <label class=\"form-check-label\" for={`${columnId}-empty-filter`}>\n Empty values\n </label>\n </div>\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n\n/**\n * Helper function to parse a numeric filter value.\n * Returns null if the filter is invalid or empty.\n *\n * @param filterValue - The filter string (e.g., \">5\", \"<=10\", \"3\")\n * @returns Parsed operator and value, or null if invalid\n */\nexport function parseNumericFilter(filterValue: string): {\n operator: '<' | '>' | '<=' | '>=' | '=';\n value: number;\n} | null {\n if (!filterValue.trim()) return null;\n\n const match = filterValue.trim().match(/^(<=?|>=?|=)?\\s*(-?\\d+\\.?\\d*)$/);\n if (!match) return null;\n\n const operator = (match[1] || '=') as '<' | '>' | '<=' | '>=' | '=';\n const value = Number.parseFloat(match[2]);\n\n if (Number.isNaN(value)) return null;\n\n return { operator, value };\n}\n\n/**\n * TanStack Table filter function for numeric columns.\n * Use this as the `filterFn` for numeric columns.\n *\n * @example\n * {\n * id: 'manual_points',\n * accessorKey: 'manual_points',\n * filterFn: numericColumnFilterFn,\n * }\n */\nexport function numericColumnFilterFn(\n row: any,\n columnId: string,\n { filterValue, emptyOnly }: NumericColumnFilterValue,\n): boolean {\n // Handle object-based filter value\n const cellValue = row.getValue(columnId) as number | null;\n const isEmpty = cellValue == null;\n\n if (emptyOnly) {\n return isEmpty;\n }\n\n // If there's no numeric filter, show all rows\n const parsed = parseNumericFilter(filterValue);\n if (!parsed) return true;\n\n // If cell is empty and we're doing numeric filtering, don't show it\n if (isEmpty) return false;\n\n // Apply numeric filter\n switch (parsed.operator) {\n case '<':\n return cellValue < parsed.value;\n case '>':\n return cellValue > parsed.value;\n case '<=':\n return cellValue <= parsed.value;\n case '>=':\n return cellValue >= parsed.value;\n case '=':\n return cellValue === parsed.value;\n default:\n return true;\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NumericInputColumnFilter.test.d.ts","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { numericColumnFilterFn, parseNumericFilter } from './NumericInputColumnFilter.js';
|
|
3
|
+
describe('parseNumericFilter', () => {
|
|
4
|
+
it('should parse equals operator', () => {
|
|
5
|
+
expect(parseNumericFilter('5')).toEqual({ operator: '=', value: 5 });
|
|
6
|
+
expect(parseNumericFilter('=5')).toEqual({ operator: '=', value: 5 });
|
|
7
|
+
expect(parseNumericFilter('= 5')).toEqual({ operator: '=', value: 5 });
|
|
8
|
+
});
|
|
9
|
+
it('should parse less than operator', () => {
|
|
10
|
+
expect(parseNumericFilter('<5')).toEqual({ operator: '<', value: 5 });
|
|
11
|
+
expect(parseNumericFilter('< 5')).toEqual({ operator: '<', value: 5 });
|
|
12
|
+
});
|
|
13
|
+
it('should parse greater than operator', () => {
|
|
14
|
+
expect(parseNumericFilter('>5')).toEqual({ operator: '>', value: 5 });
|
|
15
|
+
expect(parseNumericFilter('> 5')).toEqual({ operator: '>', value: 5 });
|
|
16
|
+
});
|
|
17
|
+
it('should parse less than or equal operator', () => {
|
|
18
|
+
expect(parseNumericFilter('<=5')).toEqual({ operator: '<=', value: 5 });
|
|
19
|
+
expect(parseNumericFilter('<= 5')).toEqual({ operator: '<=', value: 5 });
|
|
20
|
+
});
|
|
21
|
+
it('should parse greater than or equal operator', () => {
|
|
22
|
+
expect(parseNumericFilter('>=5')).toEqual({ operator: '>=', value: 5 });
|
|
23
|
+
expect(parseNumericFilter('>= 5')).toEqual({ operator: '>=', value: 5 });
|
|
24
|
+
});
|
|
25
|
+
it('should handle decimals', () => {
|
|
26
|
+
expect(parseNumericFilter('5.5')).toEqual({ operator: '=', value: 5.5 });
|
|
27
|
+
expect(parseNumericFilter('>3.14')).toEqual({ operator: '>', value: 3.14 });
|
|
28
|
+
});
|
|
29
|
+
it('should handle negative numbers', () => {
|
|
30
|
+
expect(parseNumericFilter('-5')).toEqual({ operator: '=', value: -5 });
|
|
31
|
+
expect(parseNumericFilter('<-3')).toEqual({ operator: '<', value: -3 });
|
|
32
|
+
});
|
|
33
|
+
it('should return null for invalid input', () => {
|
|
34
|
+
expect(parseNumericFilter('')).toBeNull();
|
|
35
|
+
expect(parseNumericFilter(' ')).toBeNull();
|
|
36
|
+
expect(parseNumericFilter('abc')).toBeNull();
|
|
37
|
+
expect(parseNumericFilter('>>')).toBeNull();
|
|
38
|
+
expect(parseNumericFilter('5.5.5')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it('should handle whitespace', () => {
|
|
41
|
+
expect(parseNumericFilter(' > 5 ')).toEqual({ operator: '>', value: 5 });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('numericColumnFilterFn', () => {
|
|
45
|
+
const createMockRow = (value) => ({
|
|
46
|
+
getValue: () => value,
|
|
47
|
+
});
|
|
48
|
+
it('should filter with equals operator', () => {
|
|
49
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '5', emptyOnly: false })).toBe(true);
|
|
50
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '=5', emptyOnly: false })).toBe(true);
|
|
51
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '4', emptyOnly: false })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('should filter with less than operator', () => {
|
|
54
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<5', emptyOnly: false })).toBe(true);
|
|
55
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<5', emptyOnly: false })).toBe(false);
|
|
56
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<5', emptyOnly: false })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('should filter with greater than operator', () => {
|
|
59
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>5', emptyOnly: false })).toBe(true);
|
|
60
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>5', emptyOnly: false })).toBe(false);
|
|
61
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>5', emptyOnly: false })).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('should filter with less than or equal operator', () => {
|
|
64
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<=5', emptyOnly: false })).toBe(true);
|
|
65
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<=5', emptyOnly: false })).toBe(true);
|
|
66
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<=5', emptyOnly: false })).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('should filter with greater than or equal operator', () => {
|
|
69
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>=5', emptyOnly: false })).toBe(true);
|
|
70
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>=5', emptyOnly: false })).toBe(true);
|
|
71
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>=5', emptyOnly: false })).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it('should return true for invalid or empty filter', () => {
|
|
74
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: false })).toBe(true);
|
|
75
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: 'invalid', emptyOnly: false })).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('should return false for null values when filter is active', () => {
|
|
78
|
+
expect(numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '>5', emptyOnly: false })).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('should return true for null values when filter is empty', () => {
|
|
81
|
+
expect(numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: false })).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
it('should return true for null values when emptyOnly is true', () => {
|
|
84
|
+
expect(numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: true })).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('should return false for set values when emptyOnly is true', () => {
|
|
87
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: true })).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
//# sourceMappingURL=NumericInputColumnFilter.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NumericInputColumnFilter.test.js","sourceRoot":"","sources":["../../src/components/NumericInputColumnFilter.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, expect, it } from 'vitest';\n\nimport { numericColumnFilterFn, parseNumericFilter } from './NumericInputColumnFilter.js';\n\ndescribe('parseNumericFilter', () => {\n it('should parse equals operator', () => {\n expect(parseNumericFilter('5')).toEqual({ operator: '=', value: 5 });\n expect(parseNumericFilter('=5')).toEqual({ operator: '=', value: 5 });\n expect(parseNumericFilter('= 5')).toEqual({ operator: '=', value: 5 });\n });\n\n it('should parse less than operator', () => {\n expect(parseNumericFilter('<5')).toEqual({ operator: '<', value: 5 });\n expect(parseNumericFilter('< 5')).toEqual({ operator: '<', value: 5 });\n });\n\n it('should parse greater than operator', () => {\n expect(parseNumericFilter('>5')).toEqual({ operator: '>', value: 5 });\n expect(parseNumericFilter('> 5')).toEqual({ operator: '>', value: 5 });\n });\n\n it('should parse less than or equal operator', () => {\n expect(parseNumericFilter('<=5')).toEqual({ operator: '<=', value: 5 });\n expect(parseNumericFilter('<= 5')).toEqual({ operator: '<=', value: 5 });\n });\n\n it('should parse greater than or equal operator', () => {\n expect(parseNumericFilter('>=5')).toEqual({ operator: '>=', value: 5 });\n expect(parseNumericFilter('>= 5')).toEqual({ operator: '>=', value: 5 });\n });\n\n it('should handle decimals', () => {\n expect(parseNumericFilter('5.5')).toEqual({ operator: '=', value: 5.5 });\n expect(parseNumericFilter('>3.14')).toEqual({ operator: '>', value: 3.14 });\n });\n\n it('should handle negative numbers', () => {\n expect(parseNumericFilter('-5')).toEqual({ operator: '=', value: -5 });\n expect(parseNumericFilter('<-3')).toEqual({ operator: '<', value: -3 });\n });\n\n it('should return null for invalid input', () => {\n expect(parseNumericFilter('')).toBeNull();\n expect(parseNumericFilter(' ')).toBeNull();\n expect(parseNumericFilter('abc')).toBeNull();\n expect(parseNumericFilter('>>')).toBeNull();\n expect(parseNumericFilter('5.5.5')).toBeNull();\n });\n\n it('should handle whitespace', () => {\n expect(parseNumericFilter(' > 5 ')).toEqual({ operator: '>', value: 5 });\n });\n});\n\ndescribe('numericColumnFilterFn', () => {\n const createMockRow = (value: number | null) => ({\n getValue: () => value,\n });\n\n it('should filter with equals operator', () => {\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '=5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '4', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should filter with less than operator', () => {\n expect(\n numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<5', emptyOnly: false }),\n ).toBe(false);\n expect(\n numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<5', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should filter with greater than operator', () => {\n expect(\n numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>5', emptyOnly: false }),\n ).toBe(false);\n expect(\n numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>5', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should filter with less than or equal operator', () => {\n expect(\n numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<=5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<=5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<=5', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should filter with greater than or equal operator', () => {\n expect(\n numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>=5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>=5', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>=5', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should return true for invalid or empty filter', () => {\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: false }),\n ).toBe(true);\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: 'invalid', emptyOnly: false }),\n ).toBe(true);\n });\n\n it('should return false for null values when filter is active', () => {\n expect(\n numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '>5', emptyOnly: false }),\n ).toBe(false);\n });\n\n it('should return true for null values when filter is empty', () => {\n expect(\n numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: false }),\n ).toBe(true);\n });\n it('should return true for null values when emptyOnly is true', () => {\n expect(\n numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: true }),\n ).toBe(true);\n });\n it('should return false for set values when emptyOnly is true', () => {\n expect(\n numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: true }),\n ).toBe(false);\n });\n});\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type OverlayTriggerProps as BootstrapOverlayTriggerProps, type PopoverProps, type TooltipProps } from 'react-bootstrap';
|
|
2
|
+
export interface OverlayTriggerProps extends Omit<BootstrapOverlayTriggerProps, 'overlay'> {
|
|
3
|
+
popover?: {
|
|
4
|
+
/**
|
|
5
|
+
* Additional props to pass to the Popover component.
|
|
6
|
+
*/
|
|
7
|
+
props?: Omit<PopoverProps, 'children'>;
|
|
8
|
+
/**
|
|
9
|
+
* The content to display in the popover body.
|
|
10
|
+
*/
|
|
11
|
+
body: React.ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Optional header content for the popover.
|
|
14
|
+
*/
|
|
15
|
+
header?: React.ReactNode;
|
|
16
|
+
};
|
|
17
|
+
tooltip?: {
|
|
18
|
+
/**
|
|
19
|
+
* Additional props to pass to the Tooltip component. `id` is required for accessibility.
|
|
20
|
+
*/
|
|
21
|
+
props: Omit<TooltipProps, 'children' | 'id'> & {
|
|
22
|
+
id: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The content to display in the tooltip body.
|
|
26
|
+
*/
|
|
27
|
+
body: React.ReactNode;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Whether to trap focus inside the overlay when it's shown.
|
|
31
|
+
* If true, focus will be trapped and moved to the first focusable element.
|
|
32
|
+
* @default true
|
|
33
|
+
*/
|
|
34
|
+
trapFocus?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether to return focus to the trigger element when the overlay is hidden.
|
|
37
|
+
* @default true
|
|
38
|
+
*/
|
|
39
|
+
returnFocus?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:
|
|
43
|
+
* - Automatic focus trapping when the overlay is shown
|
|
44
|
+
* - Auto-focus on the first focusable element in the overlay
|
|
45
|
+
* - Returns focus to the trigger element when the overlay is hidden
|
|
46
|
+
* - Automatically constructs a Popover with proper ref management
|
|
47
|
+
*
|
|
48
|
+
* This component provides a simpler API than react-bootstrap's OverlayTrigger by
|
|
49
|
+
* handling the Popover construction and ref management internally.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```tsx
|
|
53
|
+
* <OverlayTrigger
|
|
54
|
+
* tooltip={{
|
|
55
|
+
* body: 'Tooltip content',
|
|
56
|
+
* props: { id: 'tooltip-id' },
|
|
57
|
+
* }}
|
|
58
|
+
* placement="right"
|
|
59
|
+
* >
|
|
60
|
+
* <button>Hover me</button>
|
|
61
|
+
* </OverlayTrigger>
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <OverlayTrigger
|
|
67
|
+
* popover={{
|
|
68
|
+
* header: 'Popover title',
|
|
69
|
+
* body: 'Popover content',
|
|
70
|
+
* }}
|
|
71
|
+
* placement="right"
|
|
72
|
+
* >
|
|
73
|
+
* <button>Click me</button>
|
|
74
|
+
* </OverlayTrigger>
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export declare function OverlayTrigger({ children, popover, tooltip, trapFocus: shouldTrapFocus, returnFocus, onEntered, onExit, ...props }: OverlayTriggerProps): import("preact/compat").JSX.Element;
|
|
78
|
+
//# sourceMappingURL=OverlayTrigger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OverlayTrigger.d.ts","sourceRoot":"","sources":["../../src/components/OverlayTrigger.tsx"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,mBAAmB,IAAI,4BAA4B,EAExD,KAAK,YAAY,EAEjB,KAAK,YAAY,EAClB,MAAM,iBAAiB,CAAC;AAIzB,MAAM,WAAW,mBAAoB,SAAQ,IAAI,CAAC,4BAA4B,EAAE,SAAS,CAAC;IACxF,OAAO,CAAC,EAAE;QACR;;WAEG;QACH,KAAK,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QACvC;;WAEG;QACH,IAAI,EAAE,KAAK,CAAC,SAAS,CAAC;QACtB;;WAEG;QACH,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;KAC1B,CAAC;IACF,OAAO,CAAC,EAAE;QACR;;WAEG;QACH,KAAK,EAAE,IAAI,CAAC,YAAY,EAAE,UAAU,GAAG,IAAI,CAAC,GAAG;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9D;;WAEG;QACH,IAAI,EAAE,KAAK,CAAC,SAAS,CAAC;KACvB,CAAC;IACF;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,OAAO,EACP,OAAO,EACP,SAAS,EAAE,eAAsB,EACjC,WAAkB,EAClB,SAAS,EACT,MAAM,EACN,GAAG,KAAK,EACT,EAAE,mBAAmB,uCAuErB"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@prairielearn/preact-cjs/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from 'preact/compat';
|
|
3
|
+
import {
|
|
4
|
+
// eslint-disable-next-line no-restricted-imports
|
|
5
|
+
OverlayTrigger as BootstrapOverlayTrigger, Popover, Tooltip, } from 'react-bootstrap';
|
|
6
|
+
import { focusFirstFocusableChild, trapFocus } from '@prairielearn/browser-utils';
|
|
7
|
+
/**
|
|
8
|
+
* A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:
|
|
9
|
+
* - Automatic focus trapping when the overlay is shown
|
|
10
|
+
* - Auto-focus on the first focusable element in the overlay
|
|
11
|
+
* - Returns focus to the trigger element when the overlay is hidden
|
|
12
|
+
* - Automatically constructs a Popover with proper ref management
|
|
13
|
+
*
|
|
14
|
+
* This component provides a simpler API than react-bootstrap's OverlayTrigger by
|
|
15
|
+
* handling the Popover construction and ref management internally.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <OverlayTrigger
|
|
20
|
+
* tooltip={{
|
|
21
|
+
* body: 'Tooltip content',
|
|
22
|
+
* props: { id: 'tooltip-id' },
|
|
23
|
+
* }}
|
|
24
|
+
* placement="right"
|
|
25
|
+
* >
|
|
26
|
+
* <button>Hover me</button>
|
|
27
|
+
* </OverlayTrigger>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <OverlayTrigger
|
|
33
|
+
* popover={{
|
|
34
|
+
* header: 'Popover title',
|
|
35
|
+
* body: 'Popover content',
|
|
36
|
+
* }}
|
|
37
|
+
* placement="right"
|
|
38
|
+
* >
|
|
39
|
+
* <button>Click me</button>
|
|
40
|
+
* </OverlayTrigger>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function OverlayTrigger({ children, popover, tooltip, trapFocus: shouldTrapFocus = true, returnFocus = true, onEntered, onExit, ...props }) {
|
|
44
|
+
const overlayBodyRef = useRef(null);
|
|
45
|
+
const focusTrapRef = useRef(null);
|
|
46
|
+
const triggerElementRef = useRef(null);
|
|
47
|
+
const handleEntered = (node, isAppearing) => {
|
|
48
|
+
// Store the currently focused element (the trigger) before we move focus
|
|
49
|
+
if (returnFocus && document.activeElement instanceof HTMLElement) {
|
|
50
|
+
triggerElementRef.current = document.activeElement;
|
|
51
|
+
}
|
|
52
|
+
if (shouldTrapFocus && overlayBodyRef.current && props.trigger === 'click') {
|
|
53
|
+
// Trap focus inside the overlay body
|
|
54
|
+
focusTrapRef.current = trapFocus(overlayBodyRef.current);
|
|
55
|
+
// Move focus to the first focusable element
|
|
56
|
+
focusFirstFocusableChild(overlayBodyRef.current);
|
|
57
|
+
}
|
|
58
|
+
// Call the original onEntered callback if provided
|
|
59
|
+
onEntered?.(node, isAppearing);
|
|
60
|
+
};
|
|
61
|
+
// Deactivate the focus trap when the component unmounts
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
return () => {
|
|
64
|
+
focusTrapRef.current?.deactivate();
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
67
|
+
const handleExit = (node) => {
|
|
68
|
+
// Deactivate the focus trap
|
|
69
|
+
if (focusTrapRef.current) {
|
|
70
|
+
focusTrapRef.current.deactivate();
|
|
71
|
+
focusTrapRef.current = null;
|
|
72
|
+
}
|
|
73
|
+
// Return focus to the trigger element
|
|
74
|
+
if (returnFocus && triggerElementRef.current) {
|
|
75
|
+
triggerElementRef.current.focus();
|
|
76
|
+
triggerElementRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
// Call the original onExit callback if provided
|
|
79
|
+
onExit?.(node);
|
|
80
|
+
};
|
|
81
|
+
if (Boolean(popover) === Boolean(tooltip)) {
|
|
82
|
+
throw new Error('Only one of popover or tooltip must be provided');
|
|
83
|
+
}
|
|
84
|
+
// Construct the popover with our managed ref
|
|
85
|
+
const popoverOverlay = popover ? (_jsxs(Popover, { ...popover.props, children: [popover.header && _jsx(Popover.Header, { children: popover.header }), _jsx(Popover.Body, { ref: overlayBodyRef, children: popover.body })] })) : null;
|
|
86
|
+
const tooltipOverlay = tooltip ? _jsx(Tooltip, { ...tooltip.props, children: tooltip.body }) : null;
|
|
87
|
+
return (_jsx(BootstrapOverlayTrigger, { ...props, overlay: popoverOverlay ?? tooltipOverlay, onEntered: handleEntered, onExit: handleExit, children: children }));
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=OverlayTrigger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OverlayTrigger.js","sourceRoot":"","sources":["../../src/components/OverlayTrigger.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useEffect, useRef } from 'preact/compat';\nimport {\n // eslint-disable-next-line no-restricted-imports\n OverlayTrigger as BootstrapOverlayTrigger,\n type OverlayTriggerProps as BootstrapOverlayTriggerProps,\n Popover,\n type PopoverProps,\n Tooltip,\n type TooltipProps,\n} from 'react-bootstrap';\n\nimport { type FocusTrap, focusFirstFocusableChild, trapFocus } from '@prairielearn/browser-utils';\n\nexport interface OverlayTriggerProps extends Omit<BootstrapOverlayTriggerProps, 'overlay'> {\n popover?: {\n /**\n * Additional props to pass to the Popover component.\n */\n props?: Omit<PopoverProps, 'children'>;\n /**\n * The content to display in the popover body.\n */\n body: React.ReactNode;\n /**\n * Optional header content for the popover.\n */\n header?: React.ReactNode;\n };\n tooltip?: {\n /**\n * Additional props to pass to the Tooltip component. `id` is required for accessibility.\n */\n props: Omit<TooltipProps, 'children' | 'id'> & { id: string };\n /**\n * The content to display in the tooltip body.\n */\n body: React.ReactNode;\n };\n /**\n * Whether to trap focus inside the overlay when it's shown.\n * If true, focus will be trapped and moved to the first focusable element.\n * @default true\n */\n trapFocus?: boolean;\n /**\n * Whether to return focus to the trigger element when the overlay is hidden.\n * @default true\n */\n returnFocus?: boolean;\n}\n\n/**\n * A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:\n * - Automatic focus trapping when the overlay is shown\n * - Auto-focus on the first focusable element in the overlay\n * - Returns focus to the trigger element when the overlay is hidden\n * - Automatically constructs a Popover with proper ref management\n *\n * This component provides a simpler API than react-bootstrap's OverlayTrigger by\n * handling the Popover construction and ref management internally.\n *\n * @example\n * ```tsx\n * <OverlayTrigger\n * tooltip={{\n * body: 'Tooltip content',\n * props: { id: 'tooltip-id' },\n * }}\n * placement=\"right\"\n * >\n * <button>Hover me</button>\n * </OverlayTrigger>\n * ```\n *\n * @example\n * ```tsx\n * <OverlayTrigger\n * popover={{\n * header: 'Popover title',\n * body: 'Popover content',\n * }}\n * placement=\"right\"\n * >\n * <button>Click me</button>\n * </OverlayTrigger>\n * ```\n */\nexport function OverlayTrigger({\n children,\n popover,\n tooltip,\n trapFocus: shouldTrapFocus = true,\n returnFocus = true,\n onEntered,\n onExit,\n ...props\n}: OverlayTriggerProps) {\n const overlayBodyRef = useRef<HTMLDivElement>(null);\n const focusTrapRef = useRef<FocusTrap | null>(null);\n const triggerElementRef = useRef<HTMLElement | null>(null);\n\n const handleEntered = (node: HTMLElement, isAppearing: boolean) => {\n // Store the currently focused element (the trigger) before we move focus\n if (returnFocus && document.activeElement instanceof HTMLElement) {\n triggerElementRef.current = document.activeElement;\n }\n\n if (shouldTrapFocus && overlayBodyRef.current && props.trigger === 'click') {\n // Trap focus inside the overlay body\n focusTrapRef.current = trapFocus(overlayBodyRef.current);\n\n // Move focus to the first focusable element\n focusFirstFocusableChild(overlayBodyRef.current);\n }\n\n // Call the original onEntered callback if provided\n onEntered?.(node, isAppearing);\n };\n\n // Deactivate the focus trap when the component unmounts\n useEffect(() => {\n return () => {\n focusTrapRef.current?.deactivate();\n };\n }, []);\n\n const handleExit = (node: HTMLElement) => {\n // Deactivate the focus trap\n if (focusTrapRef.current) {\n focusTrapRef.current.deactivate();\n focusTrapRef.current = null;\n }\n\n // Return focus to the trigger element\n if (returnFocus && triggerElementRef.current) {\n triggerElementRef.current.focus();\n triggerElementRef.current = null;\n }\n\n // Call the original onExit callback if provided\n onExit?.(node);\n };\n\n if (Boolean(popover) === Boolean(tooltip)) {\n throw new Error('Only one of popover or tooltip must be provided');\n }\n\n // Construct the popover with our managed ref\n const popoverOverlay = popover ? (\n <Popover {...popover.props}>\n {popover.header && <Popover.Header>{popover.header}</Popover.Header>}\n <Popover.Body ref={overlayBodyRef}>{popover.body}</Popover.Body>\n </Popover>\n ) : null;\n\n const tooltipOverlay = tooltip ? <Tooltip {...tooltip.props}>{tooltip.body}</Tooltip> : null;\n\n return (\n <BootstrapOverlayTrigger\n {...props}\n overlay={popoverOverlay ?? tooltipOverlay!}\n onEntered={handleEntered}\n onExit={handleExit}\n >\n {children}\n </BootstrapOverlayTrigger>\n );\n}\n"]}
|