@prairielearn/ui 3.1.4 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +26 -0
- package/dist/components/CategoricalColumnFilter.d.ts +2 -2
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/FilterDropdown.d.ts +25 -0
- package/dist/components/FilterDropdown.d.ts.map +1 -0
- package/dist/components/FilterDropdown.js +45 -0
- package/dist/components/FilterDropdown.js.map +1 -0
- package/dist/components/MultiSelectColumnFilter.d.ts +2 -2
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
- package/dist/components/MultiSelectColumnFilter.js.map +1 -1
- package/dist/components/TanstackTable.d.ts +4 -4
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +1 -1
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableHeaderCell.d.ts +2 -2
- package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -1
- package/dist/components/TanstackTableHeaderCell.js.map +1 -1
- package/dist/components/useAutoSizeColumns.d.ts +2 -2
- package/dist/components/useAutoSizeColumns.d.ts.map +1 -1
- package/dist/components/useAutoSizeColumns.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CategoricalColumnFilter.tsx +2 -2
- package/src/components/FilterDropdown.tsx +163 -0
- package/src/components/MultiSelectColumnFilter.tsx +2 -2
- package/src/components/TanstackTable.tsx +4 -12
- package/src/components/TanstackTableHeaderCell.tsx +2 -2
- package/src/components/useAutoSizeColumns.tsx +3 -3
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @prairielearn/ui
|
|
2
2
|
|
|
3
|
+
## 3.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c078fcb: Add `pinnedIds` prop to `FilterDropdown` to pin specific items at the top of the list
|
|
8
|
+
- c078fcb: Add `FilterDropdown` component
|
|
9
|
+
|
|
10
|
+
## 3.1.5
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- e28f2e2: Replace `JSX.Element` with `ReactNode` in component type definitions
|
|
15
|
+
- Updated dependencies [ad329f9]
|
|
16
|
+
- Updated dependencies [7b937fb]
|
|
17
|
+
- @prairielearn/browser-utils@2.7.0
|
|
18
|
+
|
|
3
19
|
## 3.1.4
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -130,6 +130,32 @@ const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
|
130
130
|
|
|
131
131
|
Items can include `searchableText` for filtering on text different from the label, and `data` for custom data passed to `renderItem`.
|
|
132
132
|
|
|
133
|
+
### FilterDropdown
|
|
134
|
+
|
|
135
|
+
A multi-select filter dropdown built on [React Aria](https://react-spectrum.adobe.com/react-aria/).
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import { FilterDropdown, type FilterItem } from '@prairielearn/ui';
|
|
139
|
+
import { useState } from 'react';
|
|
140
|
+
|
|
141
|
+
const items: FilterItem[] = [
|
|
142
|
+
{ id: '1', name: 'JavaScript', color: 'blue1' },
|
|
143
|
+
{ id: '2', name: 'TypeScript', color: 'blue2' },
|
|
144
|
+
{ id: '3', name: 'Python', color: 'green1' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
148
|
+
|
|
149
|
+
<FilterDropdown
|
|
150
|
+
label="Language"
|
|
151
|
+
items={items}
|
|
152
|
+
selectedIds={selectedIds}
|
|
153
|
+
onChange={setSelectedIds}
|
|
154
|
+
/>;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The `color` property maps to PrairieLearn's badge color classes (e.g., `color-blue1`). Custom rendering can be provided via `renderItem`.
|
|
158
|
+
|
|
133
159
|
## nuqs Utilities
|
|
134
160
|
|
|
135
161
|
This package provides utilities for integrating [nuqs](https://nuqs.47ng.com/) (type-safe URL query state management) with server-side rendering and TanStack Table.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Column } from '@tanstack/react-table';
|
|
2
|
-
import { type
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
3
|
/**
|
|
4
4
|
* A component that allows the user to filter a categorical column.
|
|
5
5
|
* The filter mode always defaults to "include".
|
|
@@ -19,6 +19,6 @@ export declare function CategoricalColumnFilter<TData, TValue extends string = s
|
|
|
19
19
|
renderValueLabel?: (props: {
|
|
20
20
|
value: TValue;
|
|
21
21
|
isSelected: boolean;
|
|
22
|
-
}) =>
|
|
22
|
+
}) => ReactNode;
|
|
23
23
|
}): import("react/jsx-runtime").JSX.Element;
|
|
24
24
|
//# sourceMappingURL=CategoricalColumnFilter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CategoricalColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEpD,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"CategoricalColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEpD,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,OAAO,CAAC;AAkB1D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,SAAS,MAAM,GAAG,MAAM,EAAE,EAC7E,MAAM,EACN,eAAe,EACf,gBAA0C,EAC3C,EAAE;IACD,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAC/B,eAAe,EAAE,MAAM,EAAE,GAAG,SAAS,MAAM,EAAE,CAAC;IAC9C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,KAAK,SAAS,CAAC;CACjF,2CAyIA","sourcesContent":["import type { Column } from '@tanstack/react-table';\nimport clsx from 'clsx';\nimport { type ReactNode, useMemo, useState } from 'react';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction computeSelected<TValue extends string>(\n allStatusValues: TValue[] | readonly TValue[],\n mode: 'include' | 'exclude',\n selected: Set<TValue>,\n): Set<TValue> {\n if (mode === 'include') {\n return selected;\n }\n return new Set(allStatusValues.filter((s) => !selected.has(s)));\n}\n\nfunction defaultRenderValueLabel({ value }: { value: string }) {\n return <span className=\"text-nowrap\">{value}</span>;\n}\n\n/**\n * A component that allows the user to filter a categorical column.\n * The filter mode always defaults to \"include\".\n *\n * The filter options (`allColumnValues`) are strings (or string subtypes like\n * enums). The column's `filterFn` is responsible for mapping these string\n * values to the actual column data (e.g., mapping \"Unassigned\" to `null`).\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The string values to display as filter options\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function CategoricalColumnFilter<TData, TValue extends string = string>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, unknown>;\n allColumnValues: TValue[] | readonly TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => ReactNode;\n}) {\n const [mode, setMode] = useState<'include' | 'exclude'>('include');\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 computeSelected(allColumnValues, mode, new Set(columnValuesFilter));\n }, [mode, allColumnValues, columnValuesFilter]);\n\n const apply = (newMode: 'include' | 'exclude', newSelected: Set<TValue>) => {\n const selected = computeSelected(allColumnValues, newMode, newSelected);\n setMode(newMode);\n const newValue = Array.from(selected);\n column.setFilterValue(newValue);\n };\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 apply(mode, set);\n };\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n className=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n className={clsx(\n 'bi',\n selected.size > 0 ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel',\n )}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu className=\"p-0\">\n <div className=\"p-3 pb-0\">\n <div className=\"d-flex align-items-center justify-content-between mb-2\">\n <div className=\"fw-semibold text-nowrap\">{label}</div>\n <button\n type=\"button\"\n className={clsx('btn btn-link btn-sm text-decoration-none', {\n // Hide the clear button if no filters are applied.\n // Use `visibility` instead of conditional rendering to avoid layout shift.\n invisible: selected.size === 0 && mode === 'include',\n })}\n onClick={() => apply('include', new Set<TValue>())}\n >\n Clear\n </button>\n </div>\n\n <div className=\"btn-group btn-group-sm w-100 mb-2\">\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-include`}\n autoComplete=\"off\"\n checked={mode === 'include'}\n onChange={() => apply('include', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-include`}>\n <span className=\"text-nowrap\">\n {mode === 'include' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Include\n </span>\n </label>\n\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-exclude`}\n autoComplete=\"off\"\n checked={mode === 'exclude'}\n onChange={() => apply('exclude', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-exclude`}>\n <span className=\"text-nowrap\">\n {mode === 'exclude' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Exclude\n </span>\n </label>\n </div>\n </div>\n\n <div\n className=\"list-group list-group-flush\"\n style={\n {\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 } as React.CSSProperties\n }\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} className=\"list-group-item d-flex align-items-center gap-3\">\n <div className=\"form-check\">\n <input\n className=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label className=\"form-check-label fw-normal\" htmlFor={`${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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CategoricalColumnFilter.js","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { Column } from '@tanstack/react-table';\nimport clsx from 'clsx';\nimport { type JSX, useMemo, useState } from 'react';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction computeSelected<TValue extends string>(\n allStatusValues: TValue[] | readonly TValue[],\n mode: 'include' | 'exclude',\n selected: Set<TValue>,\n): Set<TValue> {\n if (mode === 'include') {\n return selected;\n }\n return new Set(allStatusValues.filter((s) => !selected.has(s)));\n}\n\nfunction defaultRenderValueLabel({ value }: { value: string }) {\n return <span className=\"text-nowrap\">{value}</span>;\n}\n\n/**\n * A component that allows the user to filter a categorical column.\n * The filter mode always defaults to \"include\".\n *\n * The filter options (`allColumnValues`) are strings (or string subtypes like\n * enums). The column's `filterFn` is responsible for mapping these string\n * values to the actual column data (e.g., mapping \"Unassigned\" to `null`).\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The string values to display as filter options\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function CategoricalColumnFilter<TData, TValue extends string = string>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, unknown>;\n allColumnValues: TValue[] | readonly TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => JSX.Element;\n}) {\n const [mode, setMode] = useState<'include' | 'exclude'>('include');\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 computeSelected(allColumnValues, mode, new Set(columnValuesFilter));\n }, [mode, allColumnValues, columnValuesFilter]);\n\n const apply = (newMode: 'include' | 'exclude', newSelected: Set<TValue>) => {\n const selected = computeSelected(allColumnValues, newMode, newSelected);\n setMode(newMode);\n const newValue = Array.from(selected);\n column.setFilterValue(newValue);\n };\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 apply(mode, set);\n };\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n className=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n className={clsx(\n 'bi',\n selected.size > 0 ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel',\n )}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu className=\"p-0\">\n <div className=\"p-3 pb-0\">\n <div className=\"d-flex align-items-center justify-content-between mb-2\">\n <div className=\"fw-semibold text-nowrap\">{label}</div>\n <button\n type=\"button\"\n className={clsx('btn btn-link btn-sm text-decoration-none', {\n // Hide the clear button if no filters are applied.\n // Use `visibility` instead of conditional rendering to avoid layout shift.\n invisible: selected.size === 0 && mode === 'include',\n })}\n onClick={() => apply('include', new Set<TValue>())}\n >\n Clear\n </button>\n </div>\n\n <div className=\"btn-group btn-group-sm w-100 mb-2\">\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-include`}\n autoComplete=\"off\"\n checked={mode === 'include'}\n onChange={() => apply('include', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-include`}>\n <span className=\"text-nowrap\">\n {mode === 'include' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Include\n </span>\n </label>\n\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-exclude`}\n autoComplete=\"off\"\n checked={mode === 'exclude'}\n onChange={() => apply('exclude', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-exclude`}>\n <span className=\"text-nowrap\">\n {mode === 'exclude' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Exclude\n </span>\n </label>\n </div>\n </div>\n\n <div\n className=\"list-group list-group-flush\"\n style={\n {\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 } as React.CSSProperties\n }\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} className=\"list-group-item d-flex align-items-center gap-3\">\n <div className=\"form-check\">\n <input\n className=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label className=\"form-check-label fw-normal\" htmlFor={`${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
|
+
{"version":3,"file":"CategoricalColumnFilter.js","sourceRoot":"","sources":["../../src/components/CategoricalColumnFilter.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { Column } from '@tanstack/react-table';\nimport clsx from 'clsx';\nimport { type ReactNode, useMemo, useState } from 'react';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction computeSelected<TValue extends string>(\n allStatusValues: TValue[] | readonly TValue[],\n mode: 'include' | 'exclude',\n selected: Set<TValue>,\n): Set<TValue> {\n if (mode === 'include') {\n return selected;\n }\n return new Set(allStatusValues.filter((s) => !selected.has(s)));\n}\n\nfunction defaultRenderValueLabel({ value }: { value: string }) {\n return <span className=\"text-nowrap\">{value}</span>;\n}\n\n/**\n * A component that allows the user to filter a categorical column.\n * The filter mode always defaults to \"include\".\n *\n * The filter options (`allColumnValues`) are strings (or string subtypes like\n * enums). The column's `filterFn` is responsible for mapping these string\n * values to the actual column data (e.g., mapping \"Unassigned\" to `null`).\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The string values to display as filter options\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function CategoricalColumnFilter<TData, TValue extends string = string>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, unknown>;\n allColumnValues: TValue[] | readonly TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => ReactNode;\n}) {\n const [mode, setMode] = useState<'include' | 'exclude'>('include');\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 computeSelected(allColumnValues, mode, new Set(columnValuesFilter));\n }, [mode, allColumnValues, columnValuesFilter]);\n\n const apply = (newMode: 'include' | 'exclude', newSelected: Set<TValue>) => {\n const selected = computeSelected(allColumnValues, newMode, newSelected);\n setMode(newMode);\n const newValue = Array.from(selected);\n column.setFilterValue(newValue);\n };\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 apply(mode, set);\n };\n\n return (\n <Dropdown align=\"end\">\n <Dropdown.Toggle\n variant=\"link\"\n className=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n className={clsx(\n 'bi',\n selected.size > 0 ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel',\n )}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu className=\"p-0\">\n <div className=\"p-3 pb-0\">\n <div className=\"d-flex align-items-center justify-content-between mb-2\">\n <div className=\"fw-semibold text-nowrap\">{label}</div>\n <button\n type=\"button\"\n className={clsx('btn btn-link btn-sm text-decoration-none', {\n // Hide the clear button if no filters are applied.\n // Use `visibility` instead of conditional rendering to avoid layout shift.\n invisible: selected.size === 0 && mode === 'include',\n })}\n onClick={() => apply('include', new Set<TValue>())}\n >\n Clear\n </button>\n </div>\n\n <div className=\"btn-group btn-group-sm w-100 mb-2\">\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-include`}\n autoComplete=\"off\"\n checked={mode === 'include'}\n onChange={() => apply('include', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-include`}>\n <span className=\"text-nowrap\">\n {mode === 'include' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Include\n </span>\n </label>\n\n <input\n type=\"radio\"\n className=\"btn-check\"\n name={`filter-${columnId}-options`}\n id={`filter-${columnId}-exclude`}\n autoComplete=\"off\"\n checked={mode === 'exclude'}\n onChange={() => apply('exclude', selected)}\n />\n <label className=\"btn btn-outline-primary\" htmlFor={`filter-${columnId}-exclude`}>\n <span className=\"text-nowrap\">\n {mode === 'exclude' && <i className=\"bi bi-check-lg me-1\" aria-hidden=\"true\" />}\n Exclude\n </span>\n </label>\n </div>\n </div>\n\n <div\n className=\"list-group list-group-flush\"\n style={\n {\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 } as React.CSSProperties\n }\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} className=\"list-group-item d-flex align-items-center gap-3\">\n <div className=\"form-check\">\n <input\n className=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label className=\"form-check-label fw-normal\" htmlFor={`${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"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface FilterItem {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
color?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FilterDropdownProps {
|
|
8
|
+
label: string;
|
|
9
|
+
items: FilterItem[];
|
|
10
|
+
selectedIds: Set<string>;
|
|
11
|
+
onChange: (selectedIds: Set<string>) => void;
|
|
12
|
+
renderItem?: (item: FilterItem, isSelected: boolean) => ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
/** Maximum height of the dropdown in pixels. */
|
|
16
|
+
maxHeight?: number;
|
|
17
|
+
/** Item IDs that should appear at the top of the list in their original order */
|
|
18
|
+
pinnedIds?: Set<string>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A multi-select filter dropdown component using react-aria-components.
|
|
22
|
+
* Displays a button trigger with selection count badge and a dropdown with checkboxes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function FilterDropdown({ label, items, selectedIds, onChange, renderItem, disabled, 'aria-label': ariaLabel, maxHeight, pinnedIds }: FilterDropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
//# sourceMappingURL=FilterDropdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FilterDropdown.d.ts","sourceRoot":"","sources":["../../src/components/FilterDropdown.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAW,MAAM,OAAO,CAAC;AAahD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,QAAQ,EAAE,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC;IAC7C,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,OAAO,KAAK,SAAS,CAAC;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAcD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,KAAK,EACL,WAAW,EACX,QAAQ,EACR,UAA8B,EAC9B,QAAgB,EAChB,YAAY,EAAE,SAAS,EACvB,SAAS,EACT,SAAS,EACV,EAAE,mBAAmB,2CAsGrB","sourcesContent":["import clsx from 'clsx';\nimport { type ReactNode, useMemo } from 'react';\nimport {\n Button,\n DialogTrigger,\n ListBox,\n ListBoxItem,\n Popover,\n type Selection,\n Separator,\n} from 'react-aria-components';\n\n// This interface isn't very generic.\n// TODO: When this component is more widely used, improve this.\nexport interface FilterItem {\n id: string;\n name: string;\n color?: string;\n}\n\nexport interface FilterDropdownProps {\n label: string;\n items: FilterItem[];\n selectedIds: Set<string>;\n onChange: (selectedIds: Set<string>) => void;\n renderItem?: (item: FilterItem, isSelected: boolean) => ReactNode;\n disabled?: boolean;\n 'aria-label'?: string;\n /** Maximum height of the dropdown in pixels. */\n maxHeight?: number;\n /** Item IDs that should appear at the top of the list in their original order */\n pinnedIds?: Set<string>;\n}\n\nfunction compareItemsByName(a: FilterItem, b: FilterItem) {\n return a.name.localeCompare(b.name, undefined, { numeric: true });\n}\n\nfunction defaultRenderItem(item: FilterItem, _isSelected: boolean) {\n return item.color ? (\n <span className={`badge color-${item.color}`}>{item.name}</span>\n ) : (\n <span>{item.name}</span>\n );\n}\n\n/**\n * A multi-select filter dropdown component using react-aria-components.\n * Displays a button trigger with selection count badge and a dropdown with checkboxes.\n */\nexport function FilterDropdown({\n label,\n items,\n selectedIds,\n onChange,\n renderItem = defaultRenderItem,\n disabled = false,\n 'aria-label': ariaLabel,\n maxHeight,\n pinnedIds,\n}: FilterDropdownProps) {\n const selectedCount = selectedIds.size;\n\n // Sort items alphabetically for display, with pinned items first\n const sortedItems = useMemo(() => {\n if (!pinnedIds || pinnedIds.size === 0) {\n return [...items].sort(compareItemsByName);\n }\n const pinned = items.filter((item) => pinnedIds.has(item.id));\n const rest = items.filter((item) => !pinnedIds.has(item.id)).sort(compareItemsByName);\n return [...pinned, ...rest];\n }, [items, pinnedIds]);\n\n const handleSelectionChange = (selection: Selection) => {\n if (selection === 'all') {\n onChange(new Set(items.map((item) => item.id)));\n } else {\n onChange(new Set(selection as Set<string>));\n }\n };\n\n const handleClear = () => {\n onChange(new Set());\n };\n\n return (\n <DialogTrigger>\n <Button\n aria-label={ariaLabel ?? `Filter by ${label}`}\n className={clsx(\n 'btn btn-sm d-flex align-items-center gap-1',\n selectedCount > 0 ? 'btn-outline-primary' : 'btn-outline-secondary',\n )}\n isDisabled={disabled}\n >\n {label}\n {selectedCount > 0 && (\n <span className=\"badge bg-primary rounded-pill\">{selectedCount}</span>\n )}\n </Button>\n <Popover\n className=\"dropdown-menu show py-0 d-flex flex-column\"\n offset={4}\n placement=\"bottom start\"\n maxHeight={maxHeight}\n style={{ width: '250px' }}\n >\n <div className=\"pt-2 flex-grow-1 overflow-auto\" style={{ minHeight: 0 }}>\n <ListBox\n aria-label={ariaLabel ?? `Filter by ${label}`}\n className=\"list-unstyled m-0\"\n items={sortedItems}\n selectedKeys={selectedIds}\n selectionMode=\"multiple\"\n selectionBehavior=\"toggle\"\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No items available</div>\n )}\n onSelectionChange={handleSelectionChange}\n >\n {(item) => (\n <ListBoxItem\n id={item.id}\n className={({ isFocused }) =>\n clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active')\n }\n style={{ cursor: 'pointer' }}\n textValue={item.name}\n >\n {({ isSelected }) => (\n <>\n <input\n checked={isSelected}\n className=\"form-check-input m-0 flex-shrink-0\"\n tabIndex={-1}\n type=\"checkbox\"\n readOnly\n />\n {renderItem(item, isSelected)}\n </>\n )}\n </ListBoxItem>\n )}\n </ListBox>\n </div>\n {selectedCount > 0 && (\n <>\n <Separator className=\"dropdown-divider mb-0\" />\n <div className=\"px-3 py-1\">\n <button\n type=\"button\"\n className=\"btn btn-sm btn-link p-0 text-decoration-none\"\n onClick={handleClear}\n >\n Clear selection\n </button>\n </div>\n </>\n )}\n </Popover>\n </DialogTrigger>\n );\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { Button, DialogTrigger, ListBox, ListBoxItem, Popover, Separator, } from 'react-aria-components';
|
|
5
|
+
function compareItemsByName(a, b) {
|
|
6
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
|
7
|
+
}
|
|
8
|
+
function defaultRenderItem(item, _isSelected) {
|
|
9
|
+
return item.color ? (_jsx("span", { className: `badge color-${item.color}`, children: item.name })) : (_jsx("span", { children: item.name }));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A multi-select filter dropdown component using react-aria-components.
|
|
13
|
+
* Displays a button trigger with selection count badge and a dropdown with checkboxes.
|
|
14
|
+
*/
|
|
15
|
+
export function FilterDropdown({ label, items, selectedIds, onChange, renderItem = defaultRenderItem, disabled = false, 'aria-label': ariaLabel, maxHeight, pinnedIds, }) {
|
|
16
|
+
const selectedCount = selectedIds.size;
|
|
17
|
+
// Sort items alphabetically for display, with pinned items first
|
|
18
|
+
const sortedItems = useMemo(() => {
|
|
19
|
+
if (!pinnedIds || pinnedIds.size === 0) {
|
|
20
|
+
return [...items].sort(compareItemsByName);
|
|
21
|
+
}
|
|
22
|
+
const pinned = items.filter((item) => pinnedIds.has(item.id));
|
|
23
|
+
const rest = items.filter((item) => !pinnedIds.has(item.id)).sort(compareItemsByName);
|
|
24
|
+
return [...pinned, ...rest];
|
|
25
|
+
}, [items, pinnedIds]);
|
|
26
|
+
const handleSelectionChange = (selection) => {
|
|
27
|
+
if (selection === 'all') {
|
|
28
|
+
onChange(new Set(items.map((item) => item.id)));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
onChange(new Set(selection));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const handleClear = () => {
|
|
35
|
+
onChange(new Set());
|
|
36
|
+
};
|
|
37
|
+
return (_jsxs(DialogTrigger, { children: [
|
|
38
|
+
_jsxs(Button, { "aria-label": ariaLabel ?? `Filter by ${label}`, className: clsx('btn btn-sm d-flex align-items-center gap-1', selectedCount > 0 ? 'btn-outline-primary' : 'btn-outline-secondary'), isDisabled: disabled, children: [label, selectedCount > 0 && (_jsx("span", { className: "badge bg-primary rounded-pill", children: selectedCount }))] }), _jsxs(Popover, { className: "dropdown-menu show py-0 d-flex flex-column", offset: 4, placement: "bottom start", maxHeight: maxHeight, style: { width: '250px' }, children: [
|
|
39
|
+
_jsx("div", { className: "pt-2 flex-grow-1 overflow-auto", style: { minHeight: 0 }, children: _jsx(ListBox, { "aria-label": ariaLabel ?? `Filter by ${label}`, className: "list-unstyled m-0", items: sortedItems, selectedKeys: selectedIds, selectionMode: "multiple", selectionBehavior: "toggle", renderEmptyState: () => (_jsx("div", { className: "dropdown-item text-muted", children: "No items available" })), onSelectionChange: handleSelectionChange, children: (item) => (_jsx(ListBoxItem, { id: item.id, className: ({ isFocused }) => clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active'), style: { cursor: 'pointer' }, textValue: item.name, children: ({ isSelected }) => (_jsxs(_Fragment, { children: [
|
|
40
|
+
_jsx("input", { checked: isSelected, className: "form-check-input m-0 flex-shrink-0", tabIndex: -1, type: "checkbox", readOnly: true }), renderItem(item, isSelected)] })) })) }) }), selectedCount > 0 && (_jsxs(_Fragment, { children: [
|
|
41
|
+
_jsx(Separator, { className: "dropdown-divider mb-0" }), _jsx("div", { className: "px-3 py-1", children: _jsx("button", { type: "button", className: "btn btn-sm btn-link p-0 text-decoration-none", onClick: handleClear, children: "Clear selection" }) })
|
|
42
|
+
] }))] })
|
|
43
|
+
] }));
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=FilterDropdown.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FilterDropdown.js","sourceRoot":"","sources":["../../src/components/FilterDropdown.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport { type ReactNode, useMemo } from 'react';\nimport {\n Button,\n DialogTrigger,\n ListBox,\n ListBoxItem,\n Popover,\n type Selection,\n Separator,\n} from 'react-aria-components';\n\n// This interface isn't very generic.\n// TODO: When this component is more widely used, improve this.\nexport interface FilterItem {\n id: string;\n name: string;\n color?: string;\n}\n\nexport interface FilterDropdownProps {\n label: string;\n items: FilterItem[];\n selectedIds: Set<string>;\n onChange: (selectedIds: Set<string>) => void;\n renderItem?: (item: FilterItem, isSelected: boolean) => ReactNode;\n disabled?: boolean;\n 'aria-label'?: string;\n /** Maximum height of the dropdown in pixels. */\n maxHeight?: number;\n /** Item IDs that should appear at the top of the list in their original order */\n pinnedIds?: Set<string>;\n}\n\nfunction compareItemsByName(a: FilterItem, b: FilterItem) {\n return a.name.localeCompare(b.name, undefined, { numeric: true });\n}\n\nfunction defaultRenderItem(item: FilterItem, _isSelected: boolean) {\n return item.color ? (\n <span className={`badge color-${item.color}`}>{item.name}</span>\n ) : (\n <span>{item.name}</span>\n );\n}\n\n/**\n * A multi-select filter dropdown component using react-aria-components.\n * Displays a button trigger with selection count badge and a dropdown with checkboxes.\n */\nexport function FilterDropdown({\n label,\n items,\n selectedIds,\n onChange,\n renderItem = defaultRenderItem,\n disabled = false,\n 'aria-label': ariaLabel,\n maxHeight,\n pinnedIds,\n}: FilterDropdownProps) {\n const selectedCount = selectedIds.size;\n\n // Sort items alphabetically for display, with pinned items first\n const sortedItems = useMemo(() => {\n if (!pinnedIds || pinnedIds.size === 0) {\n return [...items].sort(compareItemsByName);\n }\n const pinned = items.filter((item) => pinnedIds.has(item.id));\n const rest = items.filter((item) => !pinnedIds.has(item.id)).sort(compareItemsByName);\n return [...pinned, ...rest];\n }, [items, pinnedIds]);\n\n const handleSelectionChange = (selection: Selection) => {\n if (selection === 'all') {\n onChange(new Set(items.map((item) => item.id)));\n } else {\n onChange(new Set(selection as Set<string>));\n }\n };\n\n const handleClear = () => {\n onChange(new Set());\n };\n\n return (\n <DialogTrigger>\n <Button\n aria-label={ariaLabel ?? `Filter by ${label}`}\n className={clsx(\n 'btn btn-sm d-flex align-items-center gap-1',\n selectedCount > 0 ? 'btn-outline-primary' : 'btn-outline-secondary',\n )}\n isDisabled={disabled}\n >\n {label}\n {selectedCount > 0 && (\n <span className=\"badge bg-primary rounded-pill\">{selectedCount}</span>\n )}\n </Button>\n <Popover\n className=\"dropdown-menu show py-0 d-flex flex-column\"\n offset={4}\n placement=\"bottom start\"\n maxHeight={maxHeight}\n style={{ width: '250px' }}\n >\n <div className=\"pt-2 flex-grow-1 overflow-auto\" style={{ minHeight: 0 }}>\n <ListBox\n aria-label={ariaLabel ?? `Filter by ${label}`}\n className=\"list-unstyled m-0\"\n items={sortedItems}\n selectedKeys={selectedIds}\n selectionMode=\"multiple\"\n selectionBehavior=\"toggle\"\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No items available</div>\n )}\n onSelectionChange={handleSelectionChange}\n >\n {(item) => (\n <ListBoxItem\n id={item.id}\n className={({ isFocused }) =>\n clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active')\n }\n style={{ cursor: 'pointer' }}\n textValue={item.name}\n >\n {({ isSelected }) => (\n <>\n <input\n checked={isSelected}\n className=\"form-check-input m-0 flex-shrink-0\"\n tabIndex={-1}\n type=\"checkbox\"\n readOnly\n />\n {renderItem(item, isSelected)}\n </>\n )}\n </ListBoxItem>\n )}\n </ListBox>\n </div>\n {selectedCount > 0 && (\n <>\n <Separator className=\"dropdown-divider mb-0\" />\n <div className=\"px-3 py-1\">\n <button\n type=\"button\"\n className=\"btn btn-sm btn-link p-0 text-decoration-none\"\n onClick={handleClear}\n >\n Clear selection\n </button>\n </div>\n </>\n )}\n </Popover>\n </DialogTrigger>\n );\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Column } from '@tanstack/table-core';
|
|
2
|
-
import { type
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
3
|
/**
|
|
4
4
|
* A component that allows the user to filter a column containing arrays of values.
|
|
5
5
|
* Uses AND logic: rows must contain ALL selected values to match.
|
|
@@ -19,6 +19,6 @@ export declare function MultiSelectColumnFilter<TData, TValue extends string = s
|
|
|
19
19
|
renderValueLabel?: (props: {
|
|
20
20
|
value: TValue;
|
|
21
21
|
isSelected: boolean;
|
|
22
|
-
}) =>
|
|
22
|
+
}) => ReactNode;
|
|
23
23
|
}): import("react/jsx-runtime").JSX.Element;
|
|
24
24
|
//# sourceMappingURL=MultiSelectColumnFilter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiSelectColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAEnD,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"MultiSelectColumnFilter.d.ts","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAEnD,OAAO,EAAE,KAAK,SAAS,EAAW,MAAM,OAAO,CAAC;AAOhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,SAAS,MAAM,GAAG,MAAM,EAAE,EAC7E,MAAM,EACN,eAAe,EACf,gBAA0C,EAC3C,EAAE;IACD,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAC/B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,KAAK,SAAS,CAAC;CACjF,2CA0FA","sourcesContent":["import type { Column } from '@tanstack/table-core';\nimport clsx from 'clsx';\nimport { type ReactNode, useMemo } from 'react';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction defaultRenderValueLabel({ value }: { value: string }) {\n return <span>{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 * The filter options (`allColumnValues`) are strings (or string subtypes like\n * enums). The column's `filterFn` is responsible for mapping these string\n * values to the actual column data.\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The string values to display as filter options\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function MultiSelectColumnFilter<TData, TValue extends string = string>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, unknown>;\n allColumnValues: TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => ReactNode;\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 className=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n className={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu className=\"p-0\">\n <div className=\"p-3 pb-0\" style={{ minWidth: '250px' }}>\n <div className=\"d-flex align-items-center justify-content-between mb-2\">\n <div className=\"fw-semibold\">{label}</div>\n <button\n type=\"button\"\n className=\"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 className=\"list-group list-group-flush\"\n style={\n {\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 } as React.CSSProperties\n }\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} className=\"list-group-item d-flex align-items-center gap-3\">\n <div className=\"form-check\">\n <input\n className=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label className=\"form-check-label fw-normal\" htmlFor={`${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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiSelectColumnFilter.js","sourceRoot":"","sources":["../../src/components/MultiSelectColumnFilter.tsx"],"names":[],"mappings":";AACA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,
|
|
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 ReactNode, useMemo } from 'react';\nimport Dropdown from 'react-bootstrap/Dropdown';\n\nfunction defaultRenderValueLabel({ value }: { value: string }) {\n return <span>{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 * The filter options (`allColumnValues`) are strings (or string subtypes like\n * enums). The column's `filterFn` is responsible for mapping these string\n * values to the actual column data.\n *\n * @param params\n * @param params.column - The TanStack Table column object\n * @param params.allColumnValues - The string values to display as filter options\n * @param params.renderValueLabel - A function that renders the label for a value\n */\nexport function MultiSelectColumnFilter<TData, TValue extends string = string>({\n column,\n allColumnValues,\n renderValueLabel = defaultRenderValueLabel,\n}: {\n column: Column<TData, unknown>;\n allColumnValues: TValue[];\n renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => ReactNode;\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 className=\"text-muted p-0\"\n id={`filter-${columnId}`}\n aria-label={`Filter ${label.toLowerCase()}`}\n title={`Filter ${label.toLowerCase()}`}\n >\n <i\n className={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}\n aria-hidden=\"true\"\n />\n </Dropdown.Toggle>\n <Dropdown.Menu className=\"p-0\">\n <div className=\"p-3 pb-0\" style={{ minWidth: '250px' }}>\n <div className=\"d-flex align-items-center justify-content-between mb-2\">\n <div className=\"fw-semibold\">{label}</div>\n <button\n type=\"button\"\n className=\"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 className=\"list-group list-group-flush\"\n style={\n {\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 } as React.CSSProperties\n }\n >\n {allColumnValues.map((value) => {\n const isSelected = selected.has(value);\n return (\n <div key={value} className=\"list-group-item d-flex align-items-center gap-3\">\n <div className=\"form-check\">\n <input\n className=\"form-check-input\"\n type=\"checkbox\"\n checked={isSelected}\n id={`${columnId}-${value}`}\n onChange={() => toggleSelected(value)}\n />\n <label className=\"form-check-label fw-normal\" htmlFor={`${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,15 +1,15 @@
|
|
|
1
1
|
import type { Header, Table } from '@tanstack/table-core';
|
|
2
|
-
import { type ComponentProps, type
|
|
2
|
+
import { type ComponentProps, type ReactNode } from 'react';
|
|
3
3
|
import { type TanstackTableDownloadButtonProps } from './TanstackTableDownloadButton.js';
|
|
4
4
|
interface TanstackTableProps<RowDataModel> {
|
|
5
5
|
table: Table<RowDataModel>;
|
|
6
6
|
title: string;
|
|
7
7
|
filters?: Record<string, (props: {
|
|
8
8
|
header: Header<RowDataModel, unknown>;
|
|
9
|
-
}) =>
|
|
9
|
+
}) => ReactNode>;
|
|
10
10
|
rowHeight?: number;
|
|
11
|
-
noResultsState?:
|
|
12
|
-
emptyState?:
|
|
11
|
+
noResultsState?: ReactNode;
|
|
12
|
+
emptyState?: ReactNode;
|
|
13
13
|
scrollRef?: React.RefObject<HTMLDivElement | null> | null;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TanstackTable.d.ts","sourceRoot":"","sources":["../../src/components/TanstackTable.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAQ,MAAM,EAAO,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,GAAG,EACR,KAAK,SAAS,EAKf,MAAM,OAAO,CAAC;AAQf,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,kCAAkC,CAAC;AAoE1C,UAAU,kBAAkB,CAAC,YAAY;IACvC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;KAAE,KAAK,GAAG,CAAC,OAAO,CAAC,CAAC;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IAC7B,UAAU,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3D;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,EAC1C,KAAK,EACL,KAAK,EACL,OAA4B,EAC5B,SAAc,EACd,cAAsC,EACtC,UAA8B,EAC9B,SAAS,EACV,EAAE,kBAAkB,CAAC,YAAY,CAAC,2CA4WlC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,EAC9C,KAAK,EACL,KAAK,EACL,aAAa,EACb,WAAW,EACX,aAAa,EACb,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,GAAG,QAAQ,EACZ,EAAE;IACD,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,aAAa,CAAC,EAAE;QACd,OAAO,CAAC,EAAE,SAAS,CAAC;QACpB,UAAU,CAAC,EAAE,SAAS,CAAC;KACxB,CAAC;IACF,YAAY,EAAE;QACZ,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,kBAAkB,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACvE,qBAAqB,CAAC,EAAE,IAAI,CAC1B,gCAAgC,CAAC,YAAY,CAAC,EAC9C,OAAO,GAAG,eAAe,GAAG,aAAa,CAC1C,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACtD,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,2CA8FvC;AAED,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,QAAQ,EACT,EAAE;IACD,QAAQ,EAAE,MAAM,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,SAAS,CAAC;CACrB,2CAOA","sourcesContent":["import { flexRender } from '@tanstack/react-table';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport type { Cell, Header, Row, Table } from '@tanstack/table-core';\nimport clsx from 'clsx';\nimport {\n type ComponentProps,\n type JSX,\n type ReactNode,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport OverlayTrigger from 'react-bootstrap/OverlayTrigger';\nimport Tooltip from 'react-bootstrap/Tooltip';\nimport { useDebouncedCallback } from 'use-debounce';\n\nimport { run } from '@prairielearn/run';\n\nimport { ColumnManager } from './ColumnManager.js';\nimport {\n TanstackTableDownloadButton,\n type TanstackTableDownloadButtonProps,\n} from './TanstackTableDownloadButton.js';\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\nimport { useAutoSizeColumns } from './useAutoSizeColumns.js';\n\nfunction TableCell<RowDataModel>({\n cell,\n rowIdx,\n colIdx,\n canSort,\n canFilter,\n wrapText,\n handleGridKeyDown,\n}: {\n cell: Cell<RowDataModel, unknown>;\n rowIdx: number;\n colIdx: number;\n canSort: boolean;\n canFilter: boolean;\n wrapText: boolean;\n handleGridKeyDown: (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => void;\n}) {\n return (\n <td\n key={cell.id}\n tabIndex={0}\n data-grid-cell-row={rowIdx}\n data-grid-cell-col={colIdx}\n className={clsx(!canSort && !canFilter && 'text-center')}\n style={{\n display: 'flex',\n width: cell.column.getSize(),\n minWidth: 0,\n maxWidth: cell.column.getSize(),\n flexShrink: 0,\n position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,\n left: cell.column.getIsPinned() === 'left' ? cell.column.getStart() : undefined,\n verticalAlign: 'middle',\n }}\n onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}\n >\n <div\n style={{\n display: 'block',\n minWidth: 0,\n maxWidth: '100%',\n overflow: wrapText ? 'visible' : 'hidden',\n textOverflow: wrapText ? undefined : 'ellipsis',\n whiteSpace: wrapText ? 'normal' : 'nowrap',\n flex: '1 1 0%',\n width: 0, // Allow flex to control width, but start from 0\n }}\n >\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </div>\n </td>\n );\n}\n\nconst DefaultNoResultsState = (\n <TanstackTableEmptyState iconName=\"bi-search\">\n No results found matching your search criteria.\n </TanstackTableEmptyState>\n);\n\nconst DefaultEmptyState = (\n <TanstackTableEmptyState iconName=\"bi-eye-slash\">No results found.</TanstackTableEmptyState>\n);\n\ninterface TanstackTableProps<RowDataModel> {\n table: Table<RowDataModel>;\n title: string;\n filters?: Record<string, (props: { header: Header<RowDataModel, unknown> }) => JSX.Element>;\n rowHeight?: number;\n noResultsState?: JSX.Element;\n emptyState?: JSX.Element;\n scrollRef?: React.RefObject<HTMLDivElement | null> | null;\n}\n\nconst DEFAULT_FILTER_MAP = {};\n\n/**\n * A generic component that renders a full-width, resizeable Tanstack Table.\n * @param params\n * @param params.table - The table model\n * @param params.title - The title of the table\n * @param params.filters - The filters for the table\n * @param params.rowHeight - The height of the rows in the table\n * @param params.noResultsState - The no results state for the table\n * @param params.emptyState - The empty state for the table\n * @param params.scrollRef - Optional ref that will be attached to the scroll container element.\n */\nexport function TanstackTable<RowDataModel>({\n table,\n title,\n filters = DEFAULT_FILTER_MAP,\n rowHeight = 42,\n noResultsState = DefaultNoResultsState,\n emptyState = DefaultEmptyState,\n scrollRef,\n}: TanstackTableProps<RowDataModel>) {\n const parentRef = useRef<HTMLDivElement>(null);\n const tableRef = useRef<HTMLDivElement>(null);\n const scrollContainerRef = scrollRef ?? parentRef;\n\n const rows = [...table.getTopRows(), ...table.getCenterRows()];\n const rowVirtualizer = useVirtualizer({\n count: rows.length,\n getScrollElement: () => scrollContainerRef.current,\n estimateSize: () => rowHeight,\n overscan: 10,\n measureElement: (el) => el?.getBoundingClientRect().height ?? rowHeight,\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const centerColumns = visibleColumns.filter((col) => !col.getIsPinned());\n\n const columnVirtualizer = useVirtualizer({\n count: centerColumns.length,\n estimateSize: (index) => centerColumns[index]?.getSize(),\n // `useAutoSizeColumns` solves a different problem (happens once when the column set changes)\n // and we don't need to measure the cells themselves, so we can use the default estimateSize.\n getScrollElement: () => scrollContainerRef.current,\n horizontal: true,\n overscan: 3,\n });\n\n const virtualColumns = columnVirtualizer.getVirtualItems();\n\n const virtualPaddingLeft = run(() => {\n if (columnVirtualizer && virtualColumns?.length > 0) {\n return virtualColumns[0]?.start ?? 0;\n }\n return null;\n });\n\n const virtualPaddingRight = run(() => {\n if (columnVirtualizer && virtualColumns?.length > 0) {\n return (\n columnVirtualizer.getTotalSize() - (virtualColumns[virtualColumns.length - 1]?.end ?? 0)\n );\n }\n return null;\n });\n\n // Check if any column has wrapping enabled\n const hasWrappedColumns = table.getAllLeafColumns().some((col) => col.columnDef.meta?.wrapText);\n\n // Create callback for remeasuring after resize\n const handleResizeEnd = useMemo(() => {\n if (!hasWrappedColumns) return undefined;\n return () => rowVirtualizer.measure();\n }, [hasWrappedColumns, rowVirtualizer]);\n\n const getVisibleCells = (row: Row<RowDataModel>) => [\n ...row.getLeftVisibleCells(),\n ...row.getCenterVisibleCells(),\n ];\n\n const handleGridKeyDown = (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => {\n const rowLength = getVisibleCells(rows[rowIdx]).length;\n const adjacentCells: Record<KeyboardEvent['key'], { row: number; col: number }> = {\n ArrowDown: {\n row: Math.min(rows.length - 1, rowIdx + 1),\n col: colIdx,\n },\n ArrowUp: {\n row: Math.max(0, rowIdx - 1),\n col: colIdx,\n },\n ArrowRight: {\n row: rowIdx,\n col: Math.min(rowLength - 1, colIdx + 1),\n },\n ArrowLeft: {\n row: rowIdx,\n col: Math.max(0, colIdx - 1),\n },\n };\n\n const next = adjacentCells[e.key];\n\n if (!next) {\n return;\n }\n\n // Only handle arrow keys if we're in the cell itself, not in an interactive element\n const target = e.target as HTMLElement;\n if (target.tagName === 'TD') {\n // If we are on the leftmost column, we should allow left scrolling.\n if (colIdx === 0 && e.key === 'ArrowLeft') {\n return;\n }\n\n // If we are on the top row, we should allow up scrolling.\n if (rowIdx === 0 && e.key === 'ArrowUp') {\n return;\n }\n\n // If we are on the rightmost column, we should allow right scrolling.\n if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {\n return;\n }\n\n e.preventDefault();\n const selector = `[data-grid-cell-row=\"${next.row}\"][data-grid-cell-col=\"${next.col}\"]`;\n const nextCell = tableRef.current?.querySelector(selector) as HTMLElement | null;\n nextCell?.focus();\n }\n };\n\n const virtualRows = rowVirtualizer.getVirtualItems();\n\n const headerGroups = table.getHeaderGroups();\n\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n const leftPinnedHeaders = leafHeaderGroup.headers.filter(\n (header) => header.column.getIsPinned() === 'left',\n );\n const centerHeaders = leafHeaderGroup.headers.filter((header) => !header.column.getIsPinned());\n\n const isTableResizing = leafHeaderGroup.headers.some((header) => header.column.getIsResizing());\n\n // We toggle this here instead of in the parent since this component logically manages all UI for the table.\n useEffect(() => {\n document.body.classList.toggle('pl-ui-no-user-select', isTableResizing);\n }, [isTableResizing]);\n\n const hasAutoSized = useAutoSizeColumns(table, tableRef, filters);\n\n // Re-measure the virtualizer when auto-sizing completes\n useEffect(() => {\n if (hasAutoSized) {\n // https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/58\n // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-ref-to-parent\n columnVirtualizer.measure();\n }\n }, [columnVirtualizer, hasAutoSized]);\n\n const displayedCount = table.getRowModel().rows.length;\n const totalCount = table.getCoreRowModel().rows.length;\n\n return (\n <div style={{ position: 'relative' }} className=\"d-flex flex-column h-100\">\n <div\n ref={scrollContainerRef}\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n overflow: 'auto',\n overflowAnchor: 'none',\n }}\n >\n <div\n ref={tableRef}\n style={{\n position: 'relative',\n width: `max(${table.getTotalSize()}px, 100%)`,\n }}\n >\n <table\n className=\"table table-hover mb-0\"\n style={{ display: 'grid', tableLayout: 'fixed' }}\n aria-label={title}\n role=\"grid\"\n >\n <thead\n className=\"position-sticky top-0 w-100 border-top\"\n style={{\n display: 'grid',\n zIndex: 1,\n borderBottom: 'var(--bs-border-width) solid black',\n }}\n >\n <tr\n key={leafHeaderGroup.id}\n className=\"d-flex w-100\"\n style={{ minWidth: `${table.getTotalSize()}px` }}\n >\n {/* Left pinned columns */}\n {leftPinnedHeaders.map((header) => {\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n handleResizeEnd={handleResizeEnd}\n isPinned=\"left\"\n />\n );\n })}\n\n {/* Virtual padding for left side of center columns */}\n {virtualPaddingLeft ? (\n <th style={{ display: 'flex', width: virtualPaddingLeft }} />\n ) : null}\n\n {/* Virtualized center columns */}\n {virtualColumns.map((virtualColumn) => {\n const header = centerHeaders[virtualColumn.index];\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n handleResizeEnd={handleResizeEnd}\n isPinned={false}\n />\n );\n })}\n\n {/* Virtual padding for right side of center columns */}\n {virtualPaddingRight ? (\n <th style={{ display: 'flex', width: virtualPaddingRight }} />\n ) : null}\n\n {/* Filler to span remaining width */}\n <th\n tabIndex={-1}\n className=\"d-flex flex-grow-1 p-0\"\n style={{ minWidth: 0 }}\n aria-hidden=\"true\"\n />\n </tr>\n </thead>\n <tbody\n className=\"position-relative w-100\"\n style={{\n display: 'grid',\n height: `${rowVirtualizer.getTotalSize()}px`,\n }}\n >\n {virtualRows.map((virtualRow) => {\n const row = rows[virtualRow.index];\n const rowIdx = virtualRow.index;\n const leftPinnedCells = row.getLeftVisibleCells();\n const centerCells = row.getCenterVisibleCells();\n\n let currentColIdx = 0;\n\n return (\n <tr\n key={row.id}\n ref={(node) => rowVirtualizer.measureElement(node)}\n data-index={virtualRow.index}\n className=\"d-flex position-absolute w-100\"\n style={{\n transform: `translateY(${virtualRow.start}px)`,\n minWidth: `${table.getTotalSize()}px`,\n }}\n >\n {leftPinnedCells.map((cell) => {\n const colIdx = currentColIdx++;\n const canSort = cell.column.getCanSort();\n const canFilter = cell.column.getCanFilter();\n const wrapText = cell.column.columnDef.meta?.wrapText ?? false;\n\n return (\n <TableCell\n key={cell.id}\n cell={cell}\n rowIdx={rowIdx}\n colIdx={colIdx}\n canSort={canSort}\n canFilter={canFilter}\n wrapText={wrapText}\n handleGridKeyDown={handleGridKeyDown}\n />\n );\n })}\n\n {virtualPaddingLeft ? (\n <td style={{ display: 'flex', width: virtualPaddingLeft }} />\n ) : null}\n\n {virtualColumns.map((virtualColumn) => {\n const cell = centerCells[virtualColumn.index];\n if (!cell) return null;\n\n const colIdx = currentColIdx++;\n const canSort = cell.column.getCanSort();\n const canFilter = cell.column.getCanFilter();\n const wrapText = cell.column.columnDef.meta?.wrapText ?? false;\n\n return (\n <TableCell\n key={cell.id}\n cell={cell}\n rowIdx={rowIdx}\n colIdx={colIdx}\n canSort={canSort}\n canFilter={canFilter}\n wrapText={wrapText}\n handleGridKeyDown={handleGridKeyDown}\n />\n );\n })}\n\n {virtualPaddingRight ? (\n <td style={{ display: 'flex', width: virtualPaddingRight }} />\n ) : null}\n\n {/* Filler to span remaining width */}\n <td\n tabIndex={-1}\n className=\"d-flex flex-grow-1 p-0\"\n style={{ minWidth: 0 }}\n aria-hidden=\"true\"\n />\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n </div>\n {table.getVisibleLeafColumns().length === 0 || displayedCount === 0 ? (\n <div>\n <div\n className=\"d-flex flex-column justify-content-center align-items-center p-4\"\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n // Allow pointer events (e.g. scrolling) to reach the underlying table.\n pointerEvents: 'none',\n }}\n role=\"status\"\n aria-live=\"polite\"\n >\n <div\n className=\"col-lg-6\"\n style={{\n // Allow selecting and interacting with the empty state content.\n pointerEvents: 'auto',\n }}\n >\n {table.getVisibleLeafColumns().length === 0 ? (\n <TanstackTableEmptyState iconName=\"bi-eye-slash\">\n No columns selected. Use the View menu to show columns.\n </TanstackTableEmptyState>\n ) : displayedCount === 0 ? (\n totalCount > 0 ? (\n noResultsState\n ) : (\n emptyState\n )\n ) : null}\n </div>\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\n/**\n * A generic component that wraps the TanstackTable component in a card.\n * @param params\n * @param params.table - The table model\n * @param params.title - The title of the card\n * @param params.className - The class name to apply to the card\n * @param params.style - The style to apply to the card\n * @param params.singularLabel - The singular label for a single row in the table, e.g. \"student\"\n * @param params.pluralLabel - The plural label for multiple rows in the table, e.g. \"students\"\n * @param params.headerButtons - The buttons to display in the header\n * @param params.columnManager - Optional configuration for the column manager. See {@link ColumnManager} for more details.\n * @param params.columnManager.buttons - The buttons to display next to the column manager (View button)\n * @param params.columnManager.topContent - Optional content to display at the top of the column manager (View) dropdown menu\n * @param params.globalFilter - Configuration for the global filter\n * @param params.globalFilter.placeholder - Placeholder text for the search input\n * @param params.tableOptions - Specific options for the table. See {@link TanstackTableProps} for more details.\n * @param params.downloadButtonOptions - Specific options for the download button. See {@link TanstackTableDownloadButtonProps} for more details.\n */\nexport function TanstackTableCard<RowDataModel>({\n table,\n title,\n singularLabel,\n pluralLabel,\n headerButtons,\n columnManager,\n globalFilter,\n tableOptions,\n downloadButtonOptions,\n className,\n ...divProps\n}: {\n table: Table<RowDataModel>;\n title: string;\n singularLabel: string;\n pluralLabel: string;\n headerButtons?: ReactNode;\n columnManager?: {\n buttons?: ReactNode;\n topContent?: ReactNode;\n };\n globalFilter: {\n placeholder: string;\n };\n tableOptions: Partial<Omit<TanstackTableProps<RowDataModel>, 'table'>>;\n downloadButtonOptions?: Omit<\n TanstackTableDownloadButtonProps<RowDataModel>,\n 'table' | 'singularLabel' | 'pluralLabel'\n > & { pluralLabel?: string; singularLabel?: string };\n} & Omit<ComponentProps<'div'>, 'class'>) {\n const searchInputRef = useRef<HTMLInputElement>(null);\n\n const [inputValue, setInputValue] = useState(\n () => (table.getState().globalFilter as string) ?? '',\n );\n\n // Debounce the filter update\n const debouncedSetFilter = useDebouncedCallback((value: string) => {\n table.setGlobalFilter(value);\n }, 150);\n\n // Focus the search input when Ctrl+F is pressed\n useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {\n if (searchInputRef.current && searchInputRef.current !== document.activeElement) {\n searchInputRef.current.focus();\n event.preventDefault();\n }\n }\n }\n document.addEventListener('keydown', onKeyDown);\n return () => document.removeEventListener('keydown', onKeyDown);\n }, []);\n\n const displayedCount = table.getRowModel().rows.length;\n const totalCount = table.getCoreRowModel().rows.length;\n\n return (\n <div className={clsx('card d-flex flex-column', className)} {...divProps}>\n <div className=\"card-header bg-primary text-white\">\n <div className=\"d-flex align-items-center justify-content-between gap-2\">\n <div>{title}</div>\n <div className=\"d-flex gap-2\">\n {headerButtons}\n\n {downloadButtonOptions && (\n <TanstackTableDownloadButton\n table={table}\n pluralLabel={pluralLabel}\n singularLabel={singularLabel}\n {...downloadButtonOptions}\n />\n )}\n </div>\n </div>\n </div>\n <div className=\"card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2\">\n <div className=\"position-relative w-100\" style={{ maxWidth: 'min(400px, 100%)' }}>\n <input\n ref={searchInputRef}\n type=\"text\"\n className=\"form-control pl-ui-tanstack-table-search-input pl-ui-tanstack-table-focusable-shadow\"\n aria-label={globalFilter.placeholder}\n placeholder={globalFilter.placeholder}\n value={inputValue}\n autoComplete=\"off\"\n onInput={(e) => {\n const value = e.currentTarget.value;\n setInputValue(value);\n debouncedSetFilter(value);\n }}\n />\n {inputValue && (\n <OverlayTrigger overlay={<Tooltip>Clear search</Tooltip>}>\n <button\n type=\"button\"\n className=\"btn btn-floating-icon\"\n aria-label=\"Clear search\"\n onClick={() => {\n setInputValue('');\n debouncedSetFilter.cancel();\n table.setGlobalFilter('');\n }}\n >\n <i className=\"bi bi-x-circle-fill\" aria-hidden=\"true\" />\n </button>\n </OverlayTrigger>\n )}\n </div>\n <div className=\"d-flex flex-wrap flex-row align-items-center gap-2\">\n <ColumnManager table={table} topContent={columnManager?.topContent} />\n {columnManager?.buttons}\n </div>\n <div className=\"ms-auto text-muted text-nowrap\">\n Showing {displayedCount} of {totalCount} {totalCount === 1 ? singularLabel : pluralLabel}\n </div>\n </div>\n <div className=\"flex-grow-1\">\n <TanstackTable table={table} title={title} {...tableOptions} />\n </div>\n </div>\n );\n}\n\nexport function TanstackTableEmptyState({\n iconName,\n children,\n}: {\n iconName: `bi-${string}`;\n children: ReactNode;\n}) {\n return (\n <div className=\"d-flex flex-column justify-content-center align-items-center text-muted\">\n <i className={clsx('bi', iconName, 'display-4 mb-2')} aria-hidden=\"true\" />\n <div>{children}</div>\n </div>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"TanstackTable.d.ts","sourceRoot":"","sources":["../../src/components/TanstackTable.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAQ,MAAM,EAAO,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,SAAS,EAAwC,MAAM,OAAO,CAAC;AAQlG,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,kCAAkC,CAAC;AAoE1C,UAAU,kBAAkB,CAAC,YAAY;IACvC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;KAAE,KAAK,SAAS,CAAC,CAAC;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,SAAS,CAAC;IAC3B,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3D;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,EAC1C,KAAK,EACL,KAAK,EACL,OAA4B,EAC5B,SAAc,EACd,cAAsC,EACtC,UAA8B,EAC9B,SAAS,EACV,EAAE,kBAAkB,CAAC,YAAY,CAAC,2CA4WlC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,EAC9C,KAAK,EACL,KAAK,EACL,aAAa,EACb,WAAW,EACX,aAAa,EACb,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,GAAG,QAAQ,EACZ,EAAE;IACD,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,aAAa,CAAC,EAAE;QACd,OAAO,CAAC,EAAE,SAAS,CAAC;QACpB,UAAU,CAAC,EAAE,SAAS,CAAC;KACxB,CAAC;IACF,YAAY,EAAE;QACZ,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,OAAO,CAAC,IAAI,CAAC,kBAAkB,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACvE,qBAAqB,CAAC,EAAE,IAAI,CAC1B,gCAAgC,CAAC,YAAY,CAAC,EAC9C,OAAO,GAAG,eAAe,GAAG,aAAa,CAC1C,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACtD,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,2CA8FvC;AAED,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,QAAQ,EACT,EAAE;IACD,QAAQ,EAAE,MAAM,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,SAAS,CAAC;CACrB,2CAOA","sourcesContent":["import { flexRender } from '@tanstack/react-table';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport type { Cell, Header, Row, Table } from '@tanstack/table-core';\nimport clsx from 'clsx';\nimport { type ComponentProps, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';\nimport OverlayTrigger from 'react-bootstrap/OverlayTrigger';\nimport Tooltip from 'react-bootstrap/Tooltip';\nimport { useDebouncedCallback } from 'use-debounce';\n\nimport { run } from '@prairielearn/run';\n\nimport { ColumnManager } from './ColumnManager.js';\nimport {\n TanstackTableDownloadButton,\n type TanstackTableDownloadButtonProps,\n} from './TanstackTableDownloadButton.js';\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\nimport { useAutoSizeColumns } from './useAutoSizeColumns.js';\n\nfunction TableCell<RowDataModel>({\n cell,\n rowIdx,\n colIdx,\n canSort,\n canFilter,\n wrapText,\n handleGridKeyDown,\n}: {\n cell: Cell<RowDataModel, unknown>;\n rowIdx: number;\n colIdx: number;\n canSort: boolean;\n canFilter: boolean;\n wrapText: boolean;\n handleGridKeyDown: (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => void;\n}) {\n return (\n <td\n key={cell.id}\n tabIndex={0}\n data-grid-cell-row={rowIdx}\n data-grid-cell-col={colIdx}\n className={clsx(!canSort && !canFilter && 'text-center')}\n style={{\n display: 'flex',\n width: cell.column.getSize(),\n minWidth: 0,\n maxWidth: cell.column.getSize(),\n flexShrink: 0,\n position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,\n left: cell.column.getIsPinned() === 'left' ? cell.column.getStart() : undefined,\n verticalAlign: 'middle',\n }}\n onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}\n >\n <div\n style={{\n display: 'block',\n minWidth: 0,\n maxWidth: '100%',\n overflow: wrapText ? 'visible' : 'hidden',\n textOverflow: wrapText ? undefined : 'ellipsis',\n whiteSpace: wrapText ? 'normal' : 'nowrap',\n flex: '1 1 0%',\n width: 0, // Allow flex to control width, but start from 0\n }}\n >\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </div>\n </td>\n );\n}\n\nconst DefaultNoResultsState = (\n <TanstackTableEmptyState iconName=\"bi-search\">\n No results found matching your search criteria.\n </TanstackTableEmptyState>\n);\n\nconst DefaultEmptyState = (\n <TanstackTableEmptyState iconName=\"bi-eye-slash\">No results found.</TanstackTableEmptyState>\n);\n\ninterface TanstackTableProps<RowDataModel> {\n table: Table<RowDataModel>;\n title: string;\n filters?: Record<string, (props: { header: Header<RowDataModel, unknown> }) => ReactNode>;\n rowHeight?: number;\n noResultsState?: ReactNode;\n emptyState?: ReactNode;\n scrollRef?: React.RefObject<HTMLDivElement | null> | null;\n}\n\nconst DEFAULT_FILTER_MAP = {};\n\n/**\n * A generic component that renders a full-width, resizeable Tanstack Table.\n * @param params\n * @param params.table - The table model\n * @param params.title - The title of the table\n * @param params.filters - The filters for the table\n * @param params.rowHeight - The height of the rows in the table\n * @param params.noResultsState - The no results state for the table\n * @param params.emptyState - The empty state for the table\n * @param params.scrollRef - Optional ref that will be attached to the scroll container element.\n */\nexport function TanstackTable<RowDataModel>({\n table,\n title,\n filters = DEFAULT_FILTER_MAP,\n rowHeight = 42,\n noResultsState = DefaultNoResultsState,\n emptyState = DefaultEmptyState,\n scrollRef,\n}: TanstackTableProps<RowDataModel>) {\n const parentRef = useRef<HTMLDivElement>(null);\n const tableRef = useRef<HTMLDivElement>(null);\n const scrollContainerRef = scrollRef ?? parentRef;\n\n const rows = [...table.getTopRows(), ...table.getCenterRows()];\n const rowVirtualizer = useVirtualizer({\n count: rows.length,\n getScrollElement: () => scrollContainerRef.current,\n estimateSize: () => rowHeight,\n overscan: 10,\n measureElement: (el) => el?.getBoundingClientRect().height ?? rowHeight,\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const centerColumns = visibleColumns.filter((col) => !col.getIsPinned());\n\n const columnVirtualizer = useVirtualizer({\n count: centerColumns.length,\n estimateSize: (index) => centerColumns[index]?.getSize(),\n // `useAutoSizeColumns` solves a different problem (happens once when the column set changes)\n // and we don't need to measure the cells themselves, so we can use the default estimateSize.\n getScrollElement: () => scrollContainerRef.current,\n horizontal: true,\n overscan: 3,\n });\n\n const virtualColumns = columnVirtualizer.getVirtualItems();\n\n const virtualPaddingLeft = run(() => {\n if (columnVirtualizer && virtualColumns?.length > 0) {\n return virtualColumns[0]?.start ?? 0;\n }\n return null;\n });\n\n const virtualPaddingRight = run(() => {\n if (columnVirtualizer && virtualColumns?.length > 0) {\n return (\n columnVirtualizer.getTotalSize() - (virtualColumns[virtualColumns.length - 1]?.end ?? 0)\n );\n }\n return null;\n });\n\n // Check if any column has wrapping enabled\n const hasWrappedColumns = table.getAllLeafColumns().some((col) => col.columnDef.meta?.wrapText);\n\n // Create callback for remeasuring after resize\n const handleResizeEnd = useMemo(() => {\n if (!hasWrappedColumns) return undefined;\n return () => rowVirtualizer.measure();\n }, [hasWrappedColumns, rowVirtualizer]);\n\n const getVisibleCells = (row: Row<RowDataModel>) => [\n ...row.getLeftVisibleCells(),\n ...row.getCenterVisibleCells(),\n ];\n\n const handleGridKeyDown = (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => {\n const rowLength = getVisibleCells(rows[rowIdx]).length;\n const adjacentCells: Record<KeyboardEvent['key'], { row: number; col: number }> = {\n ArrowDown: {\n row: Math.min(rows.length - 1, rowIdx + 1),\n col: colIdx,\n },\n ArrowUp: {\n row: Math.max(0, rowIdx - 1),\n col: colIdx,\n },\n ArrowRight: {\n row: rowIdx,\n col: Math.min(rowLength - 1, colIdx + 1),\n },\n ArrowLeft: {\n row: rowIdx,\n col: Math.max(0, colIdx - 1),\n },\n };\n\n const next = adjacentCells[e.key];\n\n if (!next) {\n return;\n }\n\n // Only handle arrow keys if we're in the cell itself, not in an interactive element\n const target = e.target as HTMLElement;\n if (target.tagName === 'TD') {\n // If we are on the leftmost column, we should allow left scrolling.\n if (colIdx === 0 && e.key === 'ArrowLeft') {\n return;\n }\n\n // If we are on the top row, we should allow up scrolling.\n if (rowIdx === 0 && e.key === 'ArrowUp') {\n return;\n }\n\n // If we are on the rightmost column, we should allow right scrolling.\n if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {\n return;\n }\n\n e.preventDefault();\n const selector = `[data-grid-cell-row=\"${next.row}\"][data-grid-cell-col=\"${next.col}\"]`;\n const nextCell = tableRef.current?.querySelector(selector) as HTMLElement | null;\n nextCell?.focus();\n }\n };\n\n const virtualRows = rowVirtualizer.getVirtualItems();\n\n const headerGroups = table.getHeaderGroups();\n\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n const leftPinnedHeaders = leafHeaderGroup.headers.filter(\n (header) => header.column.getIsPinned() === 'left',\n );\n const centerHeaders = leafHeaderGroup.headers.filter((header) => !header.column.getIsPinned());\n\n const isTableResizing = leafHeaderGroup.headers.some((header) => header.column.getIsResizing());\n\n // We toggle this here instead of in the parent since this component logically manages all UI for the table.\n useEffect(() => {\n document.body.classList.toggle('pl-ui-no-user-select', isTableResizing);\n }, [isTableResizing]);\n\n const hasAutoSized = useAutoSizeColumns(table, tableRef, filters);\n\n // Re-measure the virtualizer when auto-sizing completes\n useEffect(() => {\n if (hasAutoSized) {\n // https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/58\n // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-ref-to-parent\n columnVirtualizer.measure();\n }\n }, [columnVirtualizer, hasAutoSized]);\n\n const displayedCount = table.getRowModel().rows.length;\n const totalCount = table.getCoreRowModel().rows.length;\n\n return (\n <div style={{ position: 'relative' }} className=\"d-flex flex-column h-100\">\n <div\n ref={scrollContainerRef}\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n overflow: 'auto',\n overflowAnchor: 'none',\n }}\n >\n <div\n ref={tableRef}\n style={{\n position: 'relative',\n width: `max(${table.getTotalSize()}px, 100%)`,\n }}\n >\n <table\n className=\"table table-hover mb-0\"\n style={{ display: 'grid', tableLayout: 'fixed' }}\n aria-label={title}\n role=\"grid\"\n >\n <thead\n className=\"position-sticky top-0 w-100 border-top\"\n style={{\n display: 'grid',\n zIndex: 1,\n borderBottom: 'var(--bs-border-width) solid black',\n }}\n >\n <tr\n key={leafHeaderGroup.id}\n className=\"d-flex w-100\"\n style={{ minWidth: `${table.getTotalSize()}px` }}\n >\n {/* Left pinned columns */}\n {leftPinnedHeaders.map((header) => {\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n handleResizeEnd={handleResizeEnd}\n isPinned=\"left\"\n />\n );\n })}\n\n {/* Virtual padding for left side of center columns */}\n {virtualPaddingLeft ? (\n <th style={{ display: 'flex', width: virtualPaddingLeft }} />\n ) : null}\n\n {/* Virtualized center columns */}\n {virtualColumns.map((virtualColumn) => {\n const header = centerHeaders[virtualColumn.index];\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n handleResizeEnd={handleResizeEnd}\n isPinned={false}\n />\n );\n })}\n\n {/* Virtual padding for right side of center columns */}\n {virtualPaddingRight ? (\n <th style={{ display: 'flex', width: virtualPaddingRight }} />\n ) : null}\n\n {/* Filler to span remaining width */}\n <th\n tabIndex={-1}\n className=\"d-flex flex-grow-1 p-0\"\n style={{ minWidth: 0 }}\n aria-hidden=\"true\"\n />\n </tr>\n </thead>\n <tbody\n className=\"position-relative w-100\"\n style={{\n display: 'grid',\n height: `${rowVirtualizer.getTotalSize()}px`,\n }}\n >\n {virtualRows.map((virtualRow) => {\n const row = rows[virtualRow.index];\n const rowIdx = virtualRow.index;\n const leftPinnedCells = row.getLeftVisibleCells();\n const centerCells = row.getCenterVisibleCells();\n\n let currentColIdx = 0;\n\n return (\n <tr\n key={row.id}\n ref={(node) => rowVirtualizer.measureElement(node)}\n data-index={virtualRow.index}\n className=\"d-flex position-absolute w-100\"\n style={{\n transform: `translateY(${virtualRow.start}px)`,\n minWidth: `${table.getTotalSize()}px`,\n }}\n >\n {leftPinnedCells.map((cell) => {\n const colIdx = currentColIdx++;\n const canSort = cell.column.getCanSort();\n const canFilter = cell.column.getCanFilter();\n const wrapText = cell.column.columnDef.meta?.wrapText ?? false;\n\n return (\n <TableCell\n key={cell.id}\n cell={cell}\n rowIdx={rowIdx}\n colIdx={colIdx}\n canSort={canSort}\n canFilter={canFilter}\n wrapText={wrapText}\n handleGridKeyDown={handleGridKeyDown}\n />\n );\n })}\n\n {virtualPaddingLeft ? (\n <td style={{ display: 'flex', width: virtualPaddingLeft }} />\n ) : null}\n\n {virtualColumns.map((virtualColumn) => {\n const cell = centerCells[virtualColumn.index];\n if (!cell) return null;\n\n const colIdx = currentColIdx++;\n const canSort = cell.column.getCanSort();\n const canFilter = cell.column.getCanFilter();\n const wrapText = cell.column.columnDef.meta?.wrapText ?? false;\n\n return (\n <TableCell\n key={cell.id}\n cell={cell}\n rowIdx={rowIdx}\n colIdx={colIdx}\n canSort={canSort}\n canFilter={canFilter}\n wrapText={wrapText}\n handleGridKeyDown={handleGridKeyDown}\n />\n );\n })}\n\n {virtualPaddingRight ? (\n <td style={{ display: 'flex', width: virtualPaddingRight }} />\n ) : null}\n\n {/* Filler to span remaining width */}\n <td\n tabIndex={-1}\n className=\"d-flex flex-grow-1 p-0\"\n style={{ minWidth: 0 }}\n aria-hidden=\"true\"\n />\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n </div>\n {table.getVisibleLeafColumns().length === 0 || displayedCount === 0 ? (\n <div>\n <div\n className=\"d-flex flex-column justify-content-center align-items-center p-4\"\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n // Allow pointer events (e.g. scrolling) to reach the underlying table.\n pointerEvents: 'none',\n }}\n role=\"status\"\n aria-live=\"polite\"\n >\n <div\n className=\"col-lg-6\"\n style={{\n // Allow selecting and interacting with the empty state content.\n pointerEvents: 'auto',\n }}\n >\n {table.getVisibleLeafColumns().length === 0 ? (\n <TanstackTableEmptyState iconName=\"bi-eye-slash\">\n No columns selected. Use the View menu to show columns.\n </TanstackTableEmptyState>\n ) : displayedCount === 0 ? (\n totalCount > 0 ? (\n noResultsState\n ) : (\n emptyState\n )\n ) : null}\n </div>\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\n/**\n * A generic component that wraps the TanstackTable component in a card.\n * @param params\n * @param params.table - The table model\n * @param params.title - The title of the card\n * @param params.className - The class name to apply to the card\n * @param params.style - The style to apply to the card\n * @param params.singularLabel - The singular label for a single row in the table, e.g. \"student\"\n * @param params.pluralLabel - The plural label for multiple rows in the table, e.g. \"students\"\n * @param params.headerButtons - The buttons to display in the header\n * @param params.columnManager - Optional configuration for the column manager. See {@link ColumnManager} for more details.\n * @param params.columnManager.buttons - The buttons to display next to the column manager (View button)\n * @param params.columnManager.topContent - Optional content to display at the top of the column manager (View) dropdown menu\n * @param params.globalFilter - Configuration for the global filter\n * @param params.globalFilter.placeholder - Placeholder text for the search input\n * @param params.tableOptions - Specific options for the table. See {@link TanstackTableProps} for more details.\n * @param params.downloadButtonOptions - Specific options for the download button. See {@link TanstackTableDownloadButtonProps} for more details.\n */\nexport function TanstackTableCard<RowDataModel>({\n table,\n title,\n singularLabel,\n pluralLabel,\n headerButtons,\n columnManager,\n globalFilter,\n tableOptions,\n downloadButtonOptions,\n className,\n ...divProps\n}: {\n table: Table<RowDataModel>;\n title: string;\n singularLabel: string;\n pluralLabel: string;\n headerButtons?: ReactNode;\n columnManager?: {\n buttons?: ReactNode;\n topContent?: ReactNode;\n };\n globalFilter: {\n placeholder: string;\n };\n tableOptions: Partial<Omit<TanstackTableProps<RowDataModel>, 'table'>>;\n downloadButtonOptions?: Omit<\n TanstackTableDownloadButtonProps<RowDataModel>,\n 'table' | 'singularLabel' | 'pluralLabel'\n > & { pluralLabel?: string; singularLabel?: string };\n} & Omit<ComponentProps<'div'>, 'class'>) {\n const searchInputRef = useRef<HTMLInputElement>(null);\n\n const [inputValue, setInputValue] = useState(\n () => (table.getState().globalFilter as string) ?? '',\n );\n\n // Debounce the filter update\n const debouncedSetFilter = useDebouncedCallback((value: string) => {\n table.setGlobalFilter(value);\n }, 150);\n\n // Focus the search input when Ctrl+F is pressed\n useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {\n if (searchInputRef.current && searchInputRef.current !== document.activeElement) {\n searchInputRef.current.focus();\n event.preventDefault();\n }\n }\n }\n document.addEventListener('keydown', onKeyDown);\n return () => document.removeEventListener('keydown', onKeyDown);\n }, []);\n\n const displayedCount = table.getRowModel().rows.length;\n const totalCount = table.getCoreRowModel().rows.length;\n\n return (\n <div className={clsx('card d-flex flex-column', className)} {...divProps}>\n <div className=\"card-header bg-primary text-white\">\n <div className=\"d-flex align-items-center justify-content-between gap-2\">\n <div>{title}</div>\n <div className=\"d-flex gap-2\">\n {headerButtons}\n\n {downloadButtonOptions && (\n <TanstackTableDownloadButton\n table={table}\n pluralLabel={pluralLabel}\n singularLabel={singularLabel}\n {...downloadButtonOptions}\n />\n )}\n </div>\n </div>\n </div>\n <div className=\"card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2\">\n <div className=\"position-relative w-100\" style={{ maxWidth: 'min(400px, 100%)' }}>\n <input\n ref={searchInputRef}\n type=\"text\"\n className=\"form-control pl-ui-tanstack-table-search-input pl-ui-tanstack-table-focusable-shadow\"\n aria-label={globalFilter.placeholder}\n placeholder={globalFilter.placeholder}\n value={inputValue}\n autoComplete=\"off\"\n onInput={(e) => {\n const value = e.currentTarget.value;\n setInputValue(value);\n debouncedSetFilter(value);\n }}\n />\n {inputValue && (\n <OverlayTrigger overlay={<Tooltip>Clear search</Tooltip>}>\n <button\n type=\"button\"\n className=\"btn btn-floating-icon\"\n aria-label=\"Clear search\"\n onClick={() => {\n setInputValue('');\n debouncedSetFilter.cancel();\n table.setGlobalFilter('');\n }}\n >\n <i className=\"bi bi-x-circle-fill\" aria-hidden=\"true\" />\n </button>\n </OverlayTrigger>\n )}\n </div>\n <div className=\"d-flex flex-wrap flex-row align-items-center gap-2\">\n <ColumnManager table={table} topContent={columnManager?.topContent} />\n {columnManager?.buttons}\n </div>\n <div className=\"ms-auto text-muted text-nowrap\">\n Showing {displayedCount} of {totalCount} {totalCount === 1 ? singularLabel : pluralLabel}\n </div>\n </div>\n <div className=\"flex-grow-1\">\n <TanstackTable table={table} title={title} {...tableOptions} />\n </div>\n </div>\n );\n}\n\nexport function TanstackTableEmptyState({\n iconName,\n children,\n}: {\n iconName: `bi-${string}`;\n children: ReactNode;\n}) {\n return (\n <div className=\"d-flex flex-column justify-content-center align-items-center text-muted\">\n <i className={clsx('bi', iconName, 'display-4 mb-2')} aria-hidden=\"true\" />\n <div>{children}</div>\n </div>\n );\n}\n"]}
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { flexRender } from '@tanstack/react-table';
|
|
3
3
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import { useEffect, useMemo, useRef, useState
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
6
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
|
7
7
|
import Tooltip from 'react-bootstrap/Tooltip';
|
|
8
8
|
import { useDebouncedCallback } from 'use-debounce';
|