@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -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 +26 -14
  6. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  7. package/dist/components/ColumnManager.d.ts +6 -2
  8. package/dist/components/ColumnManager.d.ts.map +1 -1
  9. package/dist/components/ColumnManager.js +98 -35
  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 +19 -3
  28. package/dist/components/TanstackTable.d.ts.map +1 -1
  29. package/dist/components/TanstackTable.js +159 -219
  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/styles.css +58 -0
  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 +4 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +4 -1
  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 +84 -54
  54. package/src/components/ColumnManager.tsx +236 -88
  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 +357 -410
  60. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  61. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  62. package/src/components/styles.css +58 -0
  63. package/src/components/useAutoSizeColumns.tsx +168 -0
  64. package/src/index.ts +10 -1
  65. package/src/react-table.ts +17 -0
  66. package/tsconfig.json +1 -2
  67. package/dist/components/TanstackTable.css +0 -4
  68. 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.columnId - The ID of the column
14
- * @param params.columnLabel - The label of the column, e.g. "Rubric Items"
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({ columnId, columnLabel, allColumnValues, renderValueLabel = defaultRenderValueLabel, columnValuesFilter, setColumnValuesFilter, }) {
21
- const selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);
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
- setColumnValuesFilter(Array.from(set));
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 ${columnLabel.toLowerCase()}`, title: `Filter ${columnLabel.toLowerCase()}`, children: _jsx("i", { class: clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel'), "aria-hidden": "true" }) }), _jsx(Dropdown.Menu, { class: "p-0", children: _jsxs("div", { class: "p-3", style: { minWidth: '250px' }, children: [_jsxs("div", { class: "d-flex align-items-center justify-content-between mb-2", children: [_jsx("div", { class: "fw-semibold", children: columnLabel }), _jsx("button", { type: "button", class: "btn btn-link btn-sm text-decoration-none p-0", onClick: () => setColumnValuesFilter([]), children: "Clear" })] }), _jsx("div", { class: "list-group list-group-flush", children: allColumnValues.map((value) => {
34
- const isSelected = selected.has(value);
35
- return (_jsxs("div", { class: "list-group-item d-flex align-items-center gap-3 px-0", 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({
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":"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","sourcesContent":["import 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.columnId - The ID of the column\n * @param params.columnLabel - The label of the column, e.g. \"Rubric Items\"\n * @param params.allColumnValues - All possible values that can appear in the column\n * @param params.renderValueLabel - A function that renders the label for a value\n * @param params.columnValuesFilter - The current state of the column filter\n * @param params.setColumnValuesFilter - A function that sets the state of the column filter\n */\nexport function MultiSelectColumnFilter<T extends readonly any[]>({\n columnId,\n columnLabel,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n columnValuesFilter,\n setColumnValuesFilter,\n}: {\n columnId: string;\n columnLabel: string;\n allColumnValues: T;\n renderValueLabel?: (props: { value: T[number]; isSelected: boolean }) => JSX.Element;\n columnValuesFilter: T[number][];\n setColumnValuesFilter: (value: T[number][]) => void;\n}) {\n const selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);\n\n const toggleSelected = (value: T[number]) => {\n const set = new Set(selected);\n if (set.has(value)) {\n set.delete(value);\n } else {\n set.add(value);\n }\n setColumnValuesFilter(Array.from(set));\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 ${columnLabel.toLowerCase()}`}\n title={`Filter ${columnLabel.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\" style={{ minWidth: '250px' }}>\n <div class=\"d-flex align-items-center justify-content-between mb-2\">\n <div class=\"fw-semibold\">{columnLabel}</div>\n <button\n type=\"button\"\n class=\"btn btn-link btn-sm text-decoration-none p-0\"\n onClick={() => setColumnValuesFilter([])}\n >\n Clear\n </button>\n </div>\n\n <div class=\"list-group list-group-flush\">\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 px-0\">\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 );\n })}\n </div>\n </div>\n </Dropdown.Menu>\n </Dropdown>\n );\n}\n"]}
1
+ {"version":3,"file":"MultiSelectColumnFilter.js","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":";AACA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAY,OAAO,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,QAAQ,MAAM,0BAA0B,CAAC;AAEhD,SAAS,uBAAuB,CAAI,EAAE,KAAK,EAAgB;IACzD,OAAO,yBAAO,MAAM,CAAC,KAAK,CAAC,GAAQ,CAAC;AACtC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,uBAAuB,CAAgB,EACrD,MAAM,EACN,eAAe,EACf,gBAAgB,GAAG,uBAAuB,GAM3C;IACC,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAC;IAE3B,MAAM,KAAK,GACT,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK;QAC5B,CAAC,OAAO,MAAM,CAAC,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAEtF,MAAM,kBAAkB,GAAG,MAAM,CAAC,cAAc,EAA0B,CAAC;IAE3E,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE;QAC5B,OAAO,IAAI,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACrC,CAAC,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAEzB,MAAM,cAAc,GAAG,CAAC,KAAa,EAAE,EAAE;QACvC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC9B,IAAI,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACnB,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACpB,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC;QACD,MAAM,QAAQ,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACjC,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC,CAAC;IAEF,MAAM,eAAe,GAAG,QAAQ,CAAC,IAAI,GAAG,CAAC,CAAC;IAE1C,OAAO,CACL,MAAC,QAAQ,IAAC,KAAK,EAAC,KAAK,aACnB,KAAC,QAAQ,CAAC,MAAM,IACd,OAAO,EAAC,MAAM,EACd,KAAK,EAAC,gBAAgB,EACtB,EAAE,EAAE,UAAU,QAAQ,EAAE,gBACZ,UAAU,KAAK,CAAC,WAAW,EAAE,EAAE,EAC3C,KAAK,EAAE,UAAU,KAAK,CAAC,WAAW,EAAE,EAAE,YAEtC,YACE,KAAK,EAAE,IAAI,CAAC,IAAI,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,iBACzE,MAAM,GAClB,GACc,EAClB,MAAC,QAAQ,CAAC,IAAI,IAAC,KAAK,EAAC,KAAK,aACxB,cAAK,KAAK,EAAC,UAAU,EAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,YAChD,eAAK,KAAK,EAAC,wDAAwD,aACjE,cAAK,KAAK,EAAC,aAAa,YAAE,KAAK,GAAO,EACtC,iBACE,IAAI,EAAC,QAAQ,EACb,KAAK,EAAC,8CAA8C,EACpD,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,cAAc,CAAC,EAAE,CAAC,sBAGjC,IACL,GACF,EAEN,cACE,KAAK,EAAC,6BAA6B,EACnC,KAAK,EAAE;4BACL,qEAAqE;4BACrE,gCAAgC;4BAChC,oBAAoB,EAAE,aAAa;yBACpC,YAEA,eAAe,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;4BAC7B,MAAM,UAAU,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;4BACvC,OAAO,CACL,cAAiB,KAAK,EAAC,iDAAiD,YACtE,eAAK,KAAK,EAAC,YAAY,aACrB,gBACE,KAAK,EAAC,kBAAkB,EACxB,IAAI,EAAC,UAAU,EACf,OAAO,EAAE,UAAU,EACnB,EAAE,EAAE,GAAG,QAAQ,IAAI,KAAK,EAAE,EAC1B,QAAQ,EAAE,GAAG,EAAE,CAAC,cAAc,CAAC,KAAK,CAAC,GACrC,EACF,gBAAO,KAAK,EAAC,4BAA4B,EAAC,GAAG,EAAE,GAAG,QAAQ,IAAI,KAAK,EAAE,YAClE,gBAAgB,CAAC;gDAChB,KAAK;gDACL,UAAU;6CACX,CAAC,GACI,IACJ,IAfE,KAAK,CAgBT,CACP,CAAC;wBACJ,CAAC,CAAC,GACE,IACQ,IACP,CACZ,CAAC;AACJ,CAAC","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
- interface NumericInputColumnFilterProps {
2
- columnId: string;
3
- columnLabel: string;
4
- value: string;
5
- onChange: (value: string) => void;
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.columnId - The ID of the column
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({ columnId, columnLabel, value, onChange, }: NumericInputColumnFilterProps): import("original-preact").JSX.Element;
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: string): boolean;
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":"AAGA,UAAU,6BAA6B;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,EACvC,QAAQ,EACR,WAAW,EACX,KAAK,EACL,QAAQ,GACT,EAAE,6BAA6B,yCAqE/B;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,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAqB9F"}
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.columnId - The ID of the column
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({ columnId, columnLabel, value, onChange, }) {
15
- const hasActiveFilter = value.trim().length > 0;
16
- const isInvalid = hasActiveFilter && parseNumericFilter(value) === null;
17
- 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 ${columnLabel.toLowerCase()}`, title: `Filter ${columnLabel.toLowerCase()}`, children: _jsx("i", { class: clsx('bi', isInvalid
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, { children: _jsxs("div", { class: "p-3", style: { minWidth: '240px' }, children: [_jsx("label", { class: "form-label small fw-semibold mb-2", children: columnLabel }), _jsx("input", { type: "text", class: clsx('form-control form-control-sm', isInvalid && 'is-invalid'), placeholder: "e.g., >0, <5, =10", value: value, onInput: (e) => {
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
- onChange(e.target.value);
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("div", { class: "form-text small mt-2", children: ["Use operators: ", _jsx("code", { children: "<" }), ", ", _jsx("code", { children: ">" }), ", ", _jsx("code", { children: "<=" }), ",", ' ', _jsx("code", { children: ">=" }), ", ", _jsx("code", { children: "=" }), _jsx("br", {}), "Example: ", _jsx("code", { children: ">5" }), " or ", _jsx("code", { children: "<=10" })] })), hasActiveFilter && (_jsx("button", { type: "button", class: "btn btn-sm btn-link text-decoration-none mt-2 p-0", onClick: () => onChange(''), children: "Clear filter" }))] }) })] }));
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; // Invalid or empty filter = show all
61
- const cellValue = row.getValue(columnId);
62
- if (cellValue === null || cellValue === undefined)
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>&gt;5</code> or <code>&lt;=10</code>\n </div>\n )}\n {!isInvalid && (\n <div class=\"form-text small mt-2\">\n Use operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}\n <code>&gt;=</code>, <code>=</code>\n <br />\n Example: <code>&gt;5</code> or <code>&lt;=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>&gt;5</code> or <code>&lt;=10</code>\n </div>\n )}\n {!isInvalid && (\n <small class=\"form-text text-nowrap\" id={`${columnId}-filter-description`}>\n Operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}\n <code>&gt;=</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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=NumericInputColumnFilter.test.d.ts.map
@@ -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"]}