@prairielearn/ui 3.1.5 → 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 +7 -0
- package/README.md +26 -0
- 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/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 +1 -1
- package/src/components/FilterDropdown.tsx +163 -0
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
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.
|
|
@@ -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"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export { PresetFilterDropdown } from './components/PresetFilterDropdown.js';
|
|
|
12
12
|
export { NuqsAdapter, parseAsSortingState, parseAsColumnVisibilityStateWithColumns, parseAsColumnPinningState, parseAsNumericFilter, } from './components/nuqs.js';
|
|
13
13
|
export { useModalState } from './hooks/use-modal-state.js';
|
|
14
14
|
export { ComboBox, TagPicker, type ComboBoxItem, type ComboBoxProps, type TagPickerProps, } from './components/ComboBox.js';
|
|
15
|
+
export { FilterDropdown, type FilterItem, type FilterDropdownProps, } from './components/FilterDropdown.js';
|
|
15
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EACL,2BAA2B,EAC3B,KAAK,oBAAoB,GAC1B,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,wBAAwB,GAC9B,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,uCAAuC,EACvC,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,QAAQ,EACR,SAAS,EACT,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,0BAA0B,CAAC","sourcesContent":["// Augment @tanstack/react-table types\nimport './react-table.js';\n\nexport {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport {\n TanstackTableDownloadButton,\n type TanstackTableCsvCell,\n} from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n type NumericColumnFilterValue,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\nexport { useAutoSizeColumns } from './components/useAutoSizeColumns.js';\nexport { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';\nexport { PresetFilterDropdown } from './components/PresetFilterDropdown.js';\nexport {\n NuqsAdapter,\n parseAsSortingState,\n parseAsColumnVisibilityStateWithColumns,\n parseAsColumnPinningState,\n parseAsNumericFilter,\n} from './components/nuqs.js';\n\nexport { useModalState } from './hooks/use-modal-state.js';\nexport {\n ComboBox,\n TagPicker,\n type ComboBoxItem,\n type ComboBoxProps,\n type TagPickerProps,\n} from './components/ComboBox.js';\n"]}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EACL,2BAA2B,EAC3B,KAAK,oBAAoB,GAC1B,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,wBAAwB,GAC9B,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,uCAAuC,EACvC,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,QAAQ,EACR,SAAS,EACT,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,cAAc,EACd,KAAK,UAAU,EACf,KAAK,mBAAmB,GACzB,MAAM,gCAAgC,CAAC","sourcesContent":["// Augment @tanstack/react-table types\nimport './react-table.js';\n\nexport {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport {\n TanstackTableDownloadButton,\n type TanstackTableCsvCell,\n} from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n type NumericColumnFilterValue,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\nexport { useAutoSizeColumns } from './components/useAutoSizeColumns.js';\nexport { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';\nexport { PresetFilterDropdown } from './components/PresetFilterDropdown.js';\nexport {\n NuqsAdapter,\n parseAsSortingState,\n parseAsColumnVisibilityStateWithColumns,\n parseAsColumnPinningState,\n parseAsNumericFilter,\n} from './components/nuqs.js';\n\nexport { useModalState } from './hooks/use-modal-state.js';\nexport {\n ComboBox,\n TagPicker,\n type ComboBoxItem,\n type ComboBoxProps,\n type TagPickerProps,\n} from './components/ComboBox.js';\nexport {\n FilterDropdown,\n type FilterItem,\n type FilterDropdownProps,\n} from './components/FilterDropdown.js';\n"]}
|
package/dist/index.js
CHANGED
|
@@ -13,4 +13,5 @@ export { PresetFilterDropdown } from './components/PresetFilterDropdown.js';
|
|
|
13
13
|
export { NuqsAdapter, parseAsSortingState, parseAsColumnVisibilityStateWithColumns, parseAsColumnPinningState, parseAsNumericFilter, } from './components/nuqs.js';
|
|
14
14
|
export { useModalState } from './hooks/use-modal-state.js';
|
|
15
15
|
export { ComboBox, TagPicker, } from './components/ComboBox.js';
|
|
16
|
+
export { FilterDropdown, } from './components/FilterDropdown.js';
|
|
16
17
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,OAAO,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EACL,2BAA2B,GAE5B,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GAEtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AACxE,OAAO,EAAE,cAAc,EAA4B,MAAM,gCAAgC,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,uCAAuC,EACvC,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,QAAQ,EACR,SAAS,GAIV,MAAM,0BAA0B,CAAC","sourcesContent":["// Augment @tanstack/react-table types\nimport './react-table.js';\n\nexport {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport {\n TanstackTableDownloadButton,\n type TanstackTableCsvCell,\n} from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n type NumericColumnFilterValue,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\nexport { useAutoSizeColumns } from './components/useAutoSizeColumns.js';\nexport { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';\nexport { PresetFilterDropdown } from './components/PresetFilterDropdown.js';\nexport {\n NuqsAdapter,\n parseAsSortingState,\n parseAsColumnVisibilityStateWithColumns,\n parseAsColumnPinningState,\n parseAsNumericFilter,\n} from './components/nuqs.js';\n\nexport { useModalState } from './hooks/use-modal-state.js';\nexport {\n ComboBox,\n TagPicker,\n type ComboBoxItem,\n type ComboBoxProps,\n type TagPickerProps,\n} from './components/ComboBox.js';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,OAAO,kBAAkB,CAAC;AAE1B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EACL,2BAA2B,GAE5B,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GAEtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AACxE,OAAO,EAAE,cAAc,EAA4B,MAAM,gCAAgC,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EACL,WAAW,EACX,mBAAmB,EACnB,uCAAuC,EACvC,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,QAAQ,EACR,SAAS,GAIV,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,cAAc,GAGf,MAAM,gCAAgC,CAAC","sourcesContent":["// Augment @tanstack/react-table types\nimport './react-table.js';\n\nexport {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport {\n TanstackTableDownloadButton,\n type TanstackTableCsvCell,\n} from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n type NumericColumnFilterValue,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\nexport { useAutoSizeColumns } from './components/useAutoSizeColumns.js';\nexport { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';\nexport { PresetFilterDropdown } from './components/PresetFilterDropdown.js';\nexport {\n NuqsAdapter,\n parseAsSortingState,\n parseAsColumnVisibilityStateWithColumns,\n parseAsColumnPinningState,\n parseAsNumericFilter,\n} from './components/nuqs.js';\n\nexport { useModalState } from './hooks/use-modal-state.js';\nexport {\n ComboBox,\n TagPicker,\n type ComboBoxItem,\n type ComboBoxProps,\n type TagPickerProps,\n} from './components/ComboBox.js';\nexport {\n FilterDropdown,\n type FilterItem,\n type FilterDropdownProps,\n} from './components/FilterDropdown.js';\n"]}
|
package/package.json
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { type ReactNode, useMemo } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
DialogTrigger,
|
|
6
|
+
ListBox,
|
|
7
|
+
ListBoxItem,
|
|
8
|
+
Popover,
|
|
9
|
+
type Selection,
|
|
10
|
+
Separator,
|
|
11
|
+
} from 'react-aria-components';
|
|
12
|
+
|
|
13
|
+
// This interface isn't very generic.
|
|
14
|
+
// TODO: When this component is more widely used, improve this.
|
|
15
|
+
export interface FilterItem {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
color?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FilterDropdownProps {
|
|
22
|
+
label: string;
|
|
23
|
+
items: FilterItem[];
|
|
24
|
+
selectedIds: Set<string>;
|
|
25
|
+
onChange: (selectedIds: Set<string>) => void;
|
|
26
|
+
renderItem?: (item: FilterItem, isSelected: boolean) => ReactNode;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
'aria-label'?: string;
|
|
29
|
+
/** Maximum height of the dropdown in pixels. */
|
|
30
|
+
maxHeight?: number;
|
|
31
|
+
/** Item IDs that should appear at the top of the list in their original order */
|
|
32
|
+
pinnedIds?: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compareItemsByName(a: FilterItem, b: FilterItem) {
|
|
36
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultRenderItem(item: FilterItem, _isSelected: boolean) {
|
|
40
|
+
return item.color ? (
|
|
41
|
+
<span className={`badge color-${item.color}`}>{item.name}</span>
|
|
42
|
+
) : (
|
|
43
|
+
<span>{item.name}</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A multi-select filter dropdown component using react-aria-components.
|
|
49
|
+
* Displays a button trigger with selection count badge and a dropdown with checkboxes.
|
|
50
|
+
*/
|
|
51
|
+
export function FilterDropdown({
|
|
52
|
+
label,
|
|
53
|
+
items,
|
|
54
|
+
selectedIds,
|
|
55
|
+
onChange,
|
|
56
|
+
renderItem = defaultRenderItem,
|
|
57
|
+
disabled = false,
|
|
58
|
+
'aria-label': ariaLabel,
|
|
59
|
+
maxHeight,
|
|
60
|
+
pinnedIds,
|
|
61
|
+
}: FilterDropdownProps) {
|
|
62
|
+
const selectedCount = selectedIds.size;
|
|
63
|
+
|
|
64
|
+
// Sort items alphabetically for display, with pinned items first
|
|
65
|
+
const sortedItems = useMemo(() => {
|
|
66
|
+
if (!pinnedIds || pinnedIds.size === 0) {
|
|
67
|
+
return [...items].sort(compareItemsByName);
|
|
68
|
+
}
|
|
69
|
+
const pinned = items.filter((item) => pinnedIds.has(item.id));
|
|
70
|
+
const rest = items.filter((item) => !pinnedIds.has(item.id)).sort(compareItemsByName);
|
|
71
|
+
return [...pinned, ...rest];
|
|
72
|
+
}, [items, pinnedIds]);
|
|
73
|
+
|
|
74
|
+
const handleSelectionChange = (selection: Selection) => {
|
|
75
|
+
if (selection === 'all') {
|
|
76
|
+
onChange(new Set(items.map((item) => item.id)));
|
|
77
|
+
} else {
|
|
78
|
+
onChange(new Set(selection as Set<string>));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleClear = () => {
|
|
83
|
+
onChange(new Set());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<DialogTrigger>
|
|
88
|
+
<Button
|
|
89
|
+
aria-label={ariaLabel ?? `Filter by ${label}`}
|
|
90
|
+
className={clsx(
|
|
91
|
+
'btn btn-sm d-flex align-items-center gap-1',
|
|
92
|
+
selectedCount > 0 ? 'btn-outline-primary' : 'btn-outline-secondary',
|
|
93
|
+
)}
|
|
94
|
+
isDisabled={disabled}
|
|
95
|
+
>
|
|
96
|
+
{label}
|
|
97
|
+
{selectedCount > 0 && (
|
|
98
|
+
<span className="badge bg-primary rounded-pill">{selectedCount}</span>
|
|
99
|
+
)}
|
|
100
|
+
</Button>
|
|
101
|
+
<Popover
|
|
102
|
+
className="dropdown-menu show py-0 d-flex flex-column"
|
|
103
|
+
offset={4}
|
|
104
|
+
placement="bottom start"
|
|
105
|
+
maxHeight={maxHeight}
|
|
106
|
+
style={{ width: '250px' }}
|
|
107
|
+
>
|
|
108
|
+
<div className="pt-2 flex-grow-1 overflow-auto" style={{ minHeight: 0 }}>
|
|
109
|
+
<ListBox
|
|
110
|
+
aria-label={ariaLabel ?? `Filter by ${label}`}
|
|
111
|
+
className="list-unstyled m-0"
|
|
112
|
+
items={sortedItems}
|
|
113
|
+
selectedKeys={selectedIds}
|
|
114
|
+
selectionMode="multiple"
|
|
115
|
+
selectionBehavior="toggle"
|
|
116
|
+
renderEmptyState={() => (
|
|
117
|
+
<div className="dropdown-item text-muted">No items available</div>
|
|
118
|
+
)}
|
|
119
|
+
onSelectionChange={handleSelectionChange}
|
|
120
|
+
>
|
|
121
|
+
{(item) => (
|
|
122
|
+
<ListBoxItem
|
|
123
|
+
id={item.id}
|
|
124
|
+
className={({ isFocused }) =>
|
|
125
|
+
clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active')
|
|
126
|
+
}
|
|
127
|
+
style={{ cursor: 'pointer' }}
|
|
128
|
+
textValue={item.name}
|
|
129
|
+
>
|
|
130
|
+
{({ isSelected }) => (
|
|
131
|
+
<>
|
|
132
|
+
<input
|
|
133
|
+
checked={isSelected}
|
|
134
|
+
className="form-check-input m-0 flex-shrink-0"
|
|
135
|
+
tabIndex={-1}
|
|
136
|
+
type="checkbox"
|
|
137
|
+
readOnly
|
|
138
|
+
/>
|
|
139
|
+
{renderItem(item, isSelected)}
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</ListBoxItem>
|
|
143
|
+
)}
|
|
144
|
+
</ListBox>
|
|
145
|
+
</div>
|
|
146
|
+
{selectedCount > 0 && (
|
|
147
|
+
<>
|
|
148
|
+
<Separator className="dropdown-divider mb-0" />
|
|
149
|
+
<div className="px-3 py-1">
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="btn btn-sm btn-link p-0 text-decoration-none"
|
|
153
|
+
onClick={handleClear}
|
|
154
|
+
>
|
|
155
|
+
Clear selection
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</Popover>
|
|
161
|
+
</DialogTrigger>
|
|
162
|
+
);
|
|
163
|
+
}
|