@prairielearn/ui 3.0.0 → 3.1.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @prairielearn/ui
2
2
 
3
+ ## 3.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8bdf6ea: Upgrade all JavaScript dependencies
8
+ - Updated dependencies [8bdf6ea]
9
+ - @prairielearn/browser-utils@2.6.3
10
+
11
+ ## 3.1.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 3f7e76a: Add `<ComboBox>` and `<TagPicker>` components
16
+
3
17
  ## 3.0.0
4
18
 
5
19
  ### Major Changes
package/README.md CHANGED
@@ -106,6 +106,30 @@ const tableOptions = {
106
106
  };
107
107
  ```
108
108
 
109
+ ### ComboBox and TagPicker
110
+
111
+ Accessible combobox components built on [React Aria](https://react-spectrum.adobe.com/react-aria/).
112
+
113
+ ```tsx
114
+ import { ComboBox, TagPicker, type ComboBoxItem } from '@prairielearn/ui';
115
+ import { useState } from 'react';
116
+
117
+ const items: ComboBoxItem[] = [
118
+ { id: '1', label: 'Apple' },
119
+ { id: '2', label: 'Banana' },
120
+ ];
121
+
122
+ // Single selection
123
+ const [selected, setSelected] = useState<string | null>(null);
124
+ <ComboBox items={items} value={selected} onChange={setSelected} label="Fruit" />;
125
+
126
+ // Multi-selection with tags
127
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
128
+ <TagPicker items={items} value={selectedIds} onChange={setSelectedIds} label="Fruits" />;
129
+ ```
130
+
131
+ Items can include `searchableText` for filtering on text different from the label, and `data` for custom data passed to `renderItem`.
132
+
109
133
  ## nuqs Utilities
110
134
 
111
135
  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,53 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ComboBoxProps as AriaComboBoxProps } from 'react-aria-components';
3
+ /** An item in the ComboBox or TagPicker dropdown. */
4
+ export interface ComboBoxItem<T = void> {
5
+ id: string;
6
+ label: string;
7
+ /** Custom data passed to renderItem. */
8
+ data?: T;
9
+ /** Text used for filtering (defaults to label). */
10
+ searchableText?: string;
11
+ }
12
+ type ManagedAriaProps = 'children' | 'items' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'inputValue' | 'defaultInputValue' | 'onInputChange' | 'onOpenChange' | 'menuTrigger' | 'allowsEmptyCollection' | 'isDisabled' | 'isInvalid';
13
+ export interface ComboBoxProps<T = void> extends Omit<AriaComboBoxProps<ComboBoxItem<T>>, ManagedAriaProps> {
14
+ items: ComboBoxItem<T>[];
15
+ value: string | null;
16
+ onChange: (value: string | null) => void;
17
+ placeholder?: string;
18
+ disabled?: boolean;
19
+ /** Name for hidden form input. */
20
+ name?: string;
21
+ /** ID for the input element. */
22
+ id?: string;
23
+ label?: string;
24
+ description?: string;
25
+ errorMessage?: string;
26
+ renderItem?: (item: ComboBoxItem<T>) => ReactNode;
27
+ }
28
+ export interface TagPickerProps<T = void> extends Omit<AriaComboBoxProps<ComboBoxItem<T>>, ManagedAriaProps> {
29
+ items: ComboBoxItem<T>[];
30
+ value: string[];
31
+ onChange: (value: string[]) => void;
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ /** Name for hidden form inputs. */
35
+ name?: string;
36
+ /** ID for the input element. */
37
+ id?: string;
38
+ label?: string;
39
+ description?: string;
40
+ errorMessage?: string;
41
+ renderItem?: (item: ComboBoxItem<T>) => ReactNode;
42
+ renderTag?: (item: ComboBoxItem<T>) => ReactNode;
43
+ }
44
+ /**
45
+ * Single-selection combobox with filtering.
46
+ */
47
+ export declare function ComboBox<T = void>({ items, value, onChange, placeholder, disabled, name, id, label, description, errorMessage, renderItem, ...props }: ComboBoxProps<T>): import("react/jsx-runtime").JSX.Element;
48
+ /**
49
+ * Multi-selection combobox with removable tags.
50
+ */
51
+ export declare function TagPicker<T = void>({ items, value, onChange, placeholder, disabled, name, id, label, description, errorMessage, renderItem, renderTag, ...props }: TagPickerProps<T>): import("react/jsx-runtime").JSX.Element;
52
+ export {};
53
+ //# sourceMappingURL=ComboBox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ComboBox.d.ts","sourceRoot":"","sources":["../../src/components/ComboBox.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport { type Key, type ReactNode, useMemo, useState } from 'react';\nimport { useFilter } from 'react-aria';\nimport {\n ComboBox as AriaComboBox,\n type ComboBoxProps as AriaComboBoxProps,\n Button,\n FieldError,\n Group,\n Input,\n Label,\n ListBox,\n ListBoxItem,\n Popover,\n Tag,\n TagGroup,\n TagList,\n Text,\n} from 'react-aria-components';\n\n/** An item in the ComboBox or TagPicker dropdown. */\nexport interface ComboBoxItem<T = void> {\n id: string;\n label: string;\n /** Custom data passed to renderItem. */\n data?: T;\n /** Text used for filtering (defaults to label). */\n searchableText?: string;\n}\n\ntype ManagedAriaProps =\n | 'children'\n | 'items'\n | 'selectedKey'\n | 'defaultSelectedKey'\n | 'onSelectionChange'\n | 'inputValue'\n | 'defaultInputValue'\n | 'onInputChange'\n | 'onOpenChange'\n | 'menuTrigger'\n | 'allowsEmptyCollection'\n | 'isDisabled'\n | 'isInvalid';\n\nexport interface ComboBoxProps<T = void> extends Omit<\n AriaComboBoxProps<ComboBoxItem<T>>,\n ManagedAriaProps\n> {\n items: ComboBoxItem<T>[];\n value: string | null;\n onChange: (value: string | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Name for hidden form input. */\n name?: string;\n /** ID for the input element. */\n id?: string;\n label?: string;\n description?: string;\n errorMessage?: string;\n renderItem?: (item: ComboBoxItem<T>) => ReactNode;\n}\n\nexport interface TagPickerProps<T = void> extends Omit<\n AriaComboBoxProps<ComboBoxItem<T>>,\n ManagedAriaProps\n> {\n items: ComboBoxItem<T>[];\n value: string[];\n onChange: (value: string[]) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Name for hidden form inputs. */\n name?: string;\n /** ID for the input element. */\n id?: string;\n label?: string;\n description?: string;\n errorMessage?: string;\n renderItem?: (item: ComboBoxItem<T>) => ReactNode;\n renderTag?: (item: ComboBoxItem<T>) => ReactNode;\n}\n\nfunction defaultRenderItem<T>(item: ComboBoxItem<T>) {\n return <span>{item.label}</span>;\n}\n\nfunction defaultRenderTag<T>(item: ComboBoxItem<T>) {\n return <span className=\"badge bg-secondary\">{item.label}</span>;\n}\n\n/**\n * Single-selection combobox with filtering.\n */\nexport function ComboBox<T = void>({\n items,\n value,\n onChange,\n placeholder = 'Select...',\n disabled = false,\n name,\n id,\n label,\n description,\n errorMessage,\n renderItem = defaultRenderItem,\n ...props\n}: ComboBoxProps<T>) {\n const { contains } = useFilter({ sensitivity: 'base' });\n const [isOpen, setIsOpen] = useState(false);\n const [filterText, setFilterText] = useState('');\n\n const selectedItem = useMemo(\n () => items.find((item) => item.id === value) ?? null,\n [items, value],\n );\n\n // Input value is derived: show filter text when open, selected label when closed\n const inputValue = isOpen ? filterText : (selectedItem?.label ?? '');\n\n const filteredItems = useMemo(() => {\n if (!inputValue.trim()) return items;\n return items.filter((item) => {\n const searchable = item.searchableText ?? item.label;\n return contains(searchable, inputValue);\n });\n }, [items, inputValue, contains]);\n\n const handleSelectionChange = (key: Key | null) => {\n const stringKey = typeof key === 'string' ? key : null;\n const newSelectedItem = stringKey ? items.find((item) => item.id === stringKey) : null;\n onChange(stringKey);\n // Set filter text to new label for immediate display before props update\n setFilterText(newSelectedItem?.label ?? '');\n };\n\n const handleInputChange = (inputVal: string) => {\n setFilterText(inputVal);\n if (inputVal === '' && value !== null) {\n onChange(null);\n }\n };\n\n const handleOpenChange = (open: boolean, trigger?: 'focus' | 'input' | 'manual') => {\n setIsOpen(open);\n // Initialize filter text to selected label when opening via focus\n if (open && trigger === 'focus') {\n setFilterText(selectedItem?.label ?? '');\n }\n };\n\n return (\n <div className=\"position-relative\">\n {name && <input name={name} type=\"hidden\" value={value ?? ''} />}\n\n <AriaComboBox\n {...props}\n selectedKey={value}\n inputValue={inputValue}\n isDisabled={disabled}\n isInvalid={!!errorMessage}\n menuTrigger=\"focus\"\n allowsEmptyCollection\n onSelectionChange={handleSelectionChange}\n onInputChange={handleInputChange}\n onOpenChange={handleOpenChange}\n >\n {label && <Label className=\"form-label\">{label}</Label>}\n\n <Group\n className={clsx(\n 'form-control d-flex align-items-center gap-1',\n disabled && 'bg-body-secondary',\n isOpen && 'border-primary shadow-sm',\n errorMessage && 'is-invalid',\n )}\n style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}\n >\n <Input\n className=\"border-0 flex-grow-1 bg-transparent\"\n id={id}\n placeholder={placeholder}\n style={{ outline: 'none' }}\n />\n <Button aria-label=\"Show suggestions\" className=\"border-0 bg-transparent p-0 ms-auto\">\n <i\n aria-hidden=\"true\"\n className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}\n />\n </Button>\n </Group>\n\n {description && (\n <Text className=\"form-text text-muted\" slot=\"description\">\n {description}\n </Text>\n )}\n\n <FieldError className=\"invalid-feedback d-block\">{errorMessage}</FieldError>\n\n <Popover\n className=\"dropdown-menu show py-0 overflow-auto\"\n offset={2}\n style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}\n >\n <ListBox\n className=\"list-unstyled m-0\"\n items={filteredItems}\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No options found</div>\n )}\n >\n {(item) => (\n <ListBoxItem\n id={item.id}\n className={({ isFocused, isSelected }) =>\n clsx(\n 'dropdown-item d-flex align-items-center gap-2',\n isFocused && 'active',\n isSelected && 'fw-semibold',\n )\n }\n style={{ cursor: 'pointer' }}\n textValue={item.label}\n >\n <span className=\"flex-grow-1\">{renderItem(item)}</span>\n </ListBoxItem>\n )}\n </ListBox>\n </Popover>\n </AriaComboBox>\n </div>\n );\n}\n\n/**\n * Multi-selection combobox with removable tags.\n */\nexport function TagPicker<T = void>({\n items,\n value,\n onChange,\n placeholder = 'Select...',\n disabled = false,\n name,\n id,\n label,\n description,\n errorMessage,\n renderItem = defaultRenderItem,\n renderTag = defaultRenderTag,\n ...props\n}: TagPickerProps<T>) {\n const { contains } = useFilter({ sensitivity: 'base' });\n const [inputValue, setInputValue] = useState('');\n const [isOpen, setIsOpen] = useState(false);\n\n const selectedItems = useMemo(\n () => items.filter((item) => value.includes(item.id)),\n [items, value],\n );\n\n const filteredItems = useMemo(() => {\n if (!inputValue.trim()) return items;\n return items.filter((item) => {\n const searchable = item.searchableText ?? item.label;\n return contains(searchable, inputValue);\n });\n }, [items, inputValue, contains]);\n\n const handleRemoveTag = (keys: Set<Key>) => {\n const newValue = value.filter((v) => !keys.has(v));\n onChange(newValue);\n };\n\n const handleSelect = (key: Key | null) => {\n const itemId = typeof key === 'string' ? key : null;\n if (!itemId) return;\n if (value.includes(itemId)) {\n onChange(value.filter((v) => v !== itemId));\n } else {\n onChange([...value, itemId]);\n }\n setInputValue('');\n };\n\n return (\n <div className=\"position-relative\">\n {name &&\n (selectedItems.length > 0 ? (\n selectedItems.map((item) => (\n <input key={item.id} name={name} type=\"hidden\" value={item.id} />\n ))\n ) : (\n <input name={name} type=\"hidden\" value=\"\" />\n ))}\n\n <AriaComboBox\n {...props}\n inputValue={inputValue}\n isDisabled={disabled}\n isInvalid={!!errorMessage}\n menuTrigger=\"focus\"\n selectedKey={null}\n allowsEmptyCollection\n onInputChange={setInputValue}\n onOpenChange={setIsOpen}\n onSelectionChange={handleSelect}\n >\n {label && <Label className=\"form-label\">{label}</Label>}\n\n <Group\n className={clsx(\n 'form-control d-flex flex-wrap align-items-center gap-1',\n disabled && 'bg-body-secondary',\n isOpen && 'border-primary shadow-sm',\n errorMessage && 'is-invalid',\n )}\n style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}\n >\n {selectedItems.length > 0 && (\n <TagGroup\n aria-label=\"Selected items\"\n onRemove={!disabled ? handleRemoveTag : undefined}\n >\n <TagList>\n {selectedItems.map((item) => (\n <Tag\n key={item.id}\n id={item.id}\n className=\"d-inline-flex align-items-center\"\n style={{ lineHeight: 1.2 }}\n textValue={item.label}\n >\n {renderTag(item)}\n {!disabled && (\n <Button\n aria-label={`Remove ${item.label}`}\n className=\"btn-close btn-close-sm ms-1 p-0 border-0 bg-transparent\"\n slot=\"remove\"\n style={{ fontSize: '0.6rem' }}\n />\n )}\n </Tag>\n ))}\n </TagList>\n </TagGroup>\n )}\n\n <div className=\"flex-grow-1 d-flex align-items-center\">\n <Input\n className=\"border-0 flex-grow-1 bg-transparent\"\n id={id}\n placeholder={selectedItems.length === 0 ? placeholder : ''}\n style={{ outline: 'none', minWidth: '60px' }}\n />\n <Button aria-label=\"Show suggestions\" className=\"border-0 bg-transparent p-0 ms-auto\">\n <i\n aria-hidden=\"true\"\n className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}\n />\n </Button>\n </div>\n </Group>\n\n {description && (\n <Text className=\"form-text text-muted\" slot=\"description\">\n {description}\n </Text>\n )}\n\n <FieldError className=\"invalid-feedback d-block\">{errorMessage}</FieldError>\n\n <Popover\n className=\"dropdown-menu show py-0 overflow-auto\"\n offset={2}\n style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}\n >\n <ListBox\n className=\"list-unstyled m-0\"\n items={filteredItems}\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No options found</div>\n )}\n >\n {(item) => {\n const isSelected = value.includes(item.id);\n return (\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.label}\n >\n <input\n checked={isSelected}\n className=\"form-check-input m-0\"\n tabIndex={-1}\n type=\"checkbox\"\n readOnly\n />\n <div className=\"flex-grow-1\">{renderItem(item)}</div>\n </ListBoxItem>\n );\n }}\n </ListBox>\n </Popover>\n </AriaComboBox>\n </div>\n );\n}\n"]}
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import clsx from 'clsx';
3
+ import { useMemo, useState } from 'react';
4
+ import { useFilter } from 'react-aria';
5
+ import { ComboBox as AriaComboBox, Button, FieldError, Group, Input, Label, ListBox, ListBoxItem, Popover, Tag, TagGroup, TagList, Text, } from 'react-aria-components';
6
+ function defaultRenderItem(item) {
7
+ return _jsx("span", { children: item.label });
8
+ }
9
+ function defaultRenderTag(item) {
10
+ return _jsx("span", { className: "badge bg-secondary", children: item.label });
11
+ }
12
+ /**
13
+ * Single-selection combobox with filtering.
14
+ */
15
+ export function ComboBox({ items, value, onChange, placeholder = 'Select...', disabled = false, name, id, label, description, errorMessage, renderItem = defaultRenderItem, ...props }) {
16
+ const { contains } = useFilter({ sensitivity: 'base' });
17
+ const [isOpen, setIsOpen] = useState(false);
18
+ const [filterText, setFilterText] = useState('');
19
+ const selectedItem = useMemo(() => items.find((item) => item.id === value) ?? null, [items, value]);
20
+ // Input value is derived: show filter text when open, selected label when closed
21
+ const inputValue = isOpen ? filterText : (selectedItem?.label ?? '');
22
+ const filteredItems = useMemo(() => {
23
+ if (!inputValue.trim())
24
+ return items;
25
+ return items.filter((item) => {
26
+ const searchable = item.searchableText ?? item.label;
27
+ return contains(searchable, inputValue);
28
+ });
29
+ }, [items, inputValue, contains]);
30
+ const handleSelectionChange = (key) => {
31
+ const stringKey = typeof key === 'string' ? key : null;
32
+ const newSelectedItem = stringKey ? items.find((item) => item.id === stringKey) : null;
33
+ onChange(stringKey);
34
+ // Set filter text to new label for immediate display before props update
35
+ setFilterText(newSelectedItem?.label ?? '');
36
+ };
37
+ const handleInputChange = (inputVal) => {
38
+ setFilterText(inputVal);
39
+ if (inputVal === '' && value !== null) {
40
+ onChange(null);
41
+ }
42
+ };
43
+ const handleOpenChange = (open, trigger) => {
44
+ setIsOpen(open);
45
+ // Initialize filter text to selected label when opening via focus
46
+ if (open && trigger === 'focus') {
47
+ setFilterText(selectedItem?.label ?? '');
48
+ }
49
+ };
50
+ return (_jsxs("div", { className: "position-relative", children: [name && _jsx("input", { name: name, type: "hidden", value: value ?? '' }), _jsxs(AriaComboBox, { ...props, selectedKey: value, inputValue: inputValue, isDisabled: disabled, isInvalid: !!errorMessage, menuTrigger: "focus", allowsEmptyCollection: true, onSelectionChange: handleSelectionChange, onInputChange: handleInputChange, onOpenChange: handleOpenChange, children: [label && _jsx(Label, { className: "form-label", children: label }), _jsxs(Group, { className: clsx('form-control d-flex align-items-center gap-1', disabled && 'bg-body-secondary', isOpen && 'border-primary shadow-sm', errorMessage && 'is-invalid'), style: { minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }, children: [
51
+ _jsx(Input, { className: "border-0 flex-grow-1 bg-transparent", id: id, placeholder: placeholder, style: { outline: 'none' } }), _jsx(Button, { "aria-label": "Show suggestions", className: "border-0 bg-transparent p-0 ms-auto", children: _jsx("i", { "aria-hidden": "true", className: clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted') }) })
52
+ ] }), description && (_jsx(Text, { className: "form-text text-muted", slot: "description", children: description })), _jsx(FieldError, { className: "invalid-feedback d-block", children: errorMessage }), _jsx(Popover, { className: "dropdown-menu show py-0 overflow-auto", offset: 2, style: { maxHeight: '300px', width: 'var(--trigger-width)' }, children: _jsx(ListBox, { className: "list-unstyled m-0", items: filteredItems, renderEmptyState: () => (_jsx("div", { className: "dropdown-item text-muted", children: "No options found" })), children: (item) => (_jsx(ListBoxItem, { id: item.id, className: ({ isFocused, isSelected }) => clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active', isSelected && 'fw-semibold'), style: { cursor: 'pointer' }, textValue: item.label, children: _jsx("span", { className: "flex-grow-1", children: renderItem(item) }) })) }) })
53
+ ] })
54
+ ] }));
55
+ }
56
+ /**
57
+ * Multi-selection combobox with removable tags.
58
+ */
59
+ export function TagPicker({ items, value, onChange, placeholder = 'Select...', disabled = false, name, id, label, description, errorMessage, renderItem = defaultRenderItem, renderTag = defaultRenderTag, ...props }) {
60
+ const { contains } = useFilter({ sensitivity: 'base' });
61
+ const [inputValue, setInputValue] = useState('');
62
+ const [isOpen, setIsOpen] = useState(false);
63
+ const selectedItems = useMemo(() => items.filter((item) => value.includes(item.id)), [items, value]);
64
+ const filteredItems = useMemo(() => {
65
+ if (!inputValue.trim())
66
+ return items;
67
+ return items.filter((item) => {
68
+ const searchable = item.searchableText ?? item.label;
69
+ return contains(searchable, inputValue);
70
+ });
71
+ }, [items, inputValue, contains]);
72
+ const handleRemoveTag = (keys) => {
73
+ const newValue = value.filter((v) => !keys.has(v));
74
+ onChange(newValue);
75
+ };
76
+ const handleSelect = (key) => {
77
+ const itemId = typeof key === 'string' ? key : null;
78
+ if (!itemId)
79
+ return;
80
+ if (value.includes(itemId)) {
81
+ onChange(value.filter((v) => v !== itemId));
82
+ }
83
+ else {
84
+ onChange([...value, itemId]);
85
+ }
86
+ setInputValue('');
87
+ };
88
+ return (_jsxs("div", { className: "position-relative", children: [name &&
89
+ (selectedItems.length > 0 ? (selectedItems.map((item) => (_jsx("input", { name: name, type: "hidden", value: item.id }, item.id)))) : (_jsx("input", { name: name, type: "hidden", value: "" }))), _jsxs(AriaComboBox, { ...props, inputValue: inputValue, isDisabled: disabled, isInvalid: !!errorMessage, menuTrigger: "focus", selectedKey: null, allowsEmptyCollection: true, onInputChange: setInputValue, onOpenChange: setIsOpen, onSelectionChange: handleSelect, children: [label && _jsx(Label, { className: "form-label", children: label }), _jsxs(Group, { className: clsx('form-control d-flex flex-wrap align-items-center gap-1', disabled && 'bg-body-secondary', isOpen && 'border-primary shadow-sm', errorMessage && 'is-invalid'), style: { minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }, children: [selectedItems.length > 0 && (_jsx(TagGroup, { "aria-label": "Selected items", onRemove: !disabled ? handleRemoveTag : undefined, children: _jsx(TagList, { children: selectedItems.map((item) => (_jsxs(Tag, { id: item.id, className: "d-inline-flex align-items-center", style: { lineHeight: 1.2 }, textValue: item.label, children: [renderTag(item), !disabled && (_jsx(Button, { "aria-label": `Remove ${item.label}`, className: "btn-close btn-close-sm ms-1 p-0 border-0 bg-transparent", slot: "remove", style: { fontSize: '0.6rem' } }))] }, item.id))) }) })), _jsxs("div", { className: "flex-grow-1 d-flex align-items-center", children: [
90
+ _jsx(Input, { className: "border-0 flex-grow-1 bg-transparent", id: id, placeholder: selectedItems.length === 0 ? placeholder : '', style: { outline: 'none', minWidth: '60px' } }), _jsx(Button, { "aria-label": "Show suggestions", className: "border-0 bg-transparent p-0 ms-auto", children: _jsx("i", { "aria-hidden": "true", className: clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted') }) })
91
+ ] })
92
+ ] }), description && (_jsx(Text, { className: "form-text text-muted", slot: "description", children: description })), _jsx(FieldError, { className: "invalid-feedback d-block", children: errorMessage }), _jsx(Popover, { className: "dropdown-menu show py-0 overflow-auto", offset: 2, style: { maxHeight: '300px', width: 'var(--trigger-width)' }, children: _jsx(ListBox, { className: "list-unstyled m-0", items: filteredItems, renderEmptyState: () => (_jsx("div", { className: "dropdown-item text-muted", children: "No options found" })), children: (item) => {
93
+ const isSelected = value.includes(item.id);
94
+ return (_jsxs(ListBoxItem, { id: item.id, className: ({ isFocused }) => clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active'), style: { cursor: 'pointer' }, textValue: item.label, children: [
95
+ _jsx("input", { checked: isSelected, className: "form-check-input m-0", tabIndex: -1, type: "checkbox", readOnly: true }), _jsx("div", { className: "flex-grow-1", children: renderItem(item) })
96
+ ] }));
97
+ } }) })
98
+ ] })
99
+ ] }));
100
+ }
101
+ //# sourceMappingURL=ComboBox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ComboBox.js","sourceRoot":"","sources":["../../src/components/ComboBox.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport { type Key, type ReactNode, useMemo, useState } from 'react';\nimport { useFilter } from 'react-aria';\nimport {\n ComboBox as AriaComboBox,\n type ComboBoxProps as AriaComboBoxProps,\n Button,\n FieldError,\n Group,\n Input,\n Label,\n ListBox,\n ListBoxItem,\n Popover,\n Tag,\n TagGroup,\n TagList,\n Text,\n} from 'react-aria-components';\n\n/** An item in the ComboBox or TagPicker dropdown. */\nexport interface ComboBoxItem<T = void> {\n id: string;\n label: string;\n /** Custom data passed to renderItem. */\n data?: T;\n /** Text used for filtering (defaults to label). */\n searchableText?: string;\n}\n\ntype ManagedAriaProps =\n | 'children'\n | 'items'\n | 'selectedKey'\n | 'defaultSelectedKey'\n | 'onSelectionChange'\n | 'inputValue'\n | 'defaultInputValue'\n | 'onInputChange'\n | 'onOpenChange'\n | 'menuTrigger'\n | 'allowsEmptyCollection'\n | 'isDisabled'\n | 'isInvalid';\n\nexport interface ComboBoxProps<T = void> extends Omit<\n AriaComboBoxProps<ComboBoxItem<T>>,\n ManagedAriaProps\n> {\n items: ComboBoxItem<T>[];\n value: string | null;\n onChange: (value: string | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Name for hidden form input. */\n name?: string;\n /** ID for the input element. */\n id?: string;\n label?: string;\n description?: string;\n errorMessage?: string;\n renderItem?: (item: ComboBoxItem<T>) => ReactNode;\n}\n\nexport interface TagPickerProps<T = void> extends Omit<\n AriaComboBoxProps<ComboBoxItem<T>>,\n ManagedAriaProps\n> {\n items: ComboBoxItem<T>[];\n value: string[];\n onChange: (value: string[]) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Name for hidden form inputs. */\n name?: string;\n /** ID for the input element. */\n id?: string;\n label?: string;\n description?: string;\n errorMessage?: string;\n renderItem?: (item: ComboBoxItem<T>) => ReactNode;\n renderTag?: (item: ComboBoxItem<T>) => ReactNode;\n}\n\nfunction defaultRenderItem<T>(item: ComboBoxItem<T>) {\n return <span>{item.label}</span>;\n}\n\nfunction defaultRenderTag<T>(item: ComboBoxItem<T>) {\n return <span className=\"badge bg-secondary\">{item.label}</span>;\n}\n\n/**\n * Single-selection combobox with filtering.\n */\nexport function ComboBox<T = void>({\n items,\n value,\n onChange,\n placeholder = 'Select...',\n disabled = false,\n name,\n id,\n label,\n description,\n errorMessage,\n renderItem = defaultRenderItem,\n ...props\n}: ComboBoxProps<T>) {\n const { contains } = useFilter({ sensitivity: 'base' });\n const [isOpen, setIsOpen] = useState(false);\n const [filterText, setFilterText] = useState('');\n\n const selectedItem = useMemo(\n () => items.find((item) => item.id === value) ?? null,\n [items, value],\n );\n\n // Input value is derived: show filter text when open, selected label when closed\n const inputValue = isOpen ? filterText : (selectedItem?.label ?? '');\n\n const filteredItems = useMemo(() => {\n if (!inputValue.trim()) return items;\n return items.filter((item) => {\n const searchable = item.searchableText ?? item.label;\n return contains(searchable, inputValue);\n });\n }, [items, inputValue, contains]);\n\n const handleSelectionChange = (key: Key | null) => {\n const stringKey = typeof key === 'string' ? key : null;\n const newSelectedItem = stringKey ? items.find((item) => item.id === stringKey) : null;\n onChange(stringKey);\n // Set filter text to new label for immediate display before props update\n setFilterText(newSelectedItem?.label ?? '');\n };\n\n const handleInputChange = (inputVal: string) => {\n setFilterText(inputVal);\n if (inputVal === '' && value !== null) {\n onChange(null);\n }\n };\n\n const handleOpenChange = (open: boolean, trigger?: 'focus' | 'input' | 'manual') => {\n setIsOpen(open);\n // Initialize filter text to selected label when opening via focus\n if (open && trigger === 'focus') {\n setFilterText(selectedItem?.label ?? '');\n }\n };\n\n return (\n <div className=\"position-relative\">\n {name && <input name={name} type=\"hidden\" value={value ?? ''} />}\n\n <AriaComboBox\n {...props}\n selectedKey={value}\n inputValue={inputValue}\n isDisabled={disabled}\n isInvalid={!!errorMessage}\n menuTrigger=\"focus\"\n allowsEmptyCollection\n onSelectionChange={handleSelectionChange}\n onInputChange={handleInputChange}\n onOpenChange={handleOpenChange}\n >\n {label && <Label className=\"form-label\">{label}</Label>}\n\n <Group\n className={clsx(\n 'form-control d-flex align-items-center gap-1',\n disabled && 'bg-body-secondary',\n isOpen && 'border-primary shadow-sm',\n errorMessage && 'is-invalid',\n )}\n style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}\n >\n <Input\n className=\"border-0 flex-grow-1 bg-transparent\"\n id={id}\n placeholder={placeholder}\n style={{ outline: 'none' }}\n />\n <Button aria-label=\"Show suggestions\" className=\"border-0 bg-transparent p-0 ms-auto\">\n <i\n aria-hidden=\"true\"\n className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}\n />\n </Button>\n </Group>\n\n {description && (\n <Text className=\"form-text text-muted\" slot=\"description\">\n {description}\n </Text>\n )}\n\n <FieldError className=\"invalid-feedback d-block\">{errorMessage}</FieldError>\n\n <Popover\n className=\"dropdown-menu show py-0 overflow-auto\"\n offset={2}\n style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}\n >\n <ListBox\n className=\"list-unstyled m-0\"\n items={filteredItems}\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No options found</div>\n )}\n >\n {(item) => (\n <ListBoxItem\n id={item.id}\n className={({ isFocused, isSelected }) =>\n clsx(\n 'dropdown-item d-flex align-items-center gap-2',\n isFocused && 'active',\n isSelected && 'fw-semibold',\n )\n }\n style={{ cursor: 'pointer' }}\n textValue={item.label}\n >\n <span className=\"flex-grow-1\">{renderItem(item)}</span>\n </ListBoxItem>\n )}\n </ListBox>\n </Popover>\n </AriaComboBox>\n </div>\n );\n}\n\n/**\n * Multi-selection combobox with removable tags.\n */\nexport function TagPicker<T = void>({\n items,\n value,\n onChange,\n placeholder = 'Select...',\n disabled = false,\n name,\n id,\n label,\n description,\n errorMessage,\n renderItem = defaultRenderItem,\n renderTag = defaultRenderTag,\n ...props\n}: TagPickerProps<T>) {\n const { contains } = useFilter({ sensitivity: 'base' });\n const [inputValue, setInputValue] = useState('');\n const [isOpen, setIsOpen] = useState(false);\n\n const selectedItems = useMemo(\n () => items.filter((item) => value.includes(item.id)),\n [items, value],\n );\n\n const filteredItems = useMemo(() => {\n if (!inputValue.trim()) return items;\n return items.filter((item) => {\n const searchable = item.searchableText ?? item.label;\n return contains(searchable, inputValue);\n });\n }, [items, inputValue, contains]);\n\n const handleRemoveTag = (keys: Set<Key>) => {\n const newValue = value.filter((v) => !keys.has(v));\n onChange(newValue);\n };\n\n const handleSelect = (key: Key | null) => {\n const itemId = typeof key === 'string' ? key : null;\n if (!itemId) return;\n if (value.includes(itemId)) {\n onChange(value.filter((v) => v !== itemId));\n } else {\n onChange([...value, itemId]);\n }\n setInputValue('');\n };\n\n return (\n <div className=\"position-relative\">\n {name &&\n (selectedItems.length > 0 ? (\n selectedItems.map((item) => (\n <input key={item.id} name={name} type=\"hidden\" value={item.id} />\n ))\n ) : (\n <input name={name} type=\"hidden\" value=\"\" />\n ))}\n\n <AriaComboBox\n {...props}\n inputValue={inputValue}\n isDisabled={disabled}\n isInvalid={!!errorMessage}\n menuTrigger=\"focus\"\n selectedKey={null}\n allowsEmptyCollection\n onInputChange={setInputValue}\n onOpenChange={setIsOpen}\n onSelectionChange={handleSelect}\n >\n {label && <Label className=\"form-label\">{label}</Label>}\n\n <Group\n className={clsx(\n 'form-control d-flex flex-wrap align-items-center gap-1',\n disabled && 'bg-body-secondary',\n isOpen && 'border-primary shadow-sm',\n errorMessage && 'is-invalid',\n )}\n style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}\n >\n {selectedItems.length > 0 && (\n <TagGroup\n aria-label=\"Selected items\"\n onRemove={!disabled ? handleRemoveTag : undefined}\n >\n <TagList>\n {selectedItems.map((item) => (\n <Tag\n key={item.id}\n id={item.id}\n className=\"d-inline-flex align-items-center\"\n style={{ lineHeight: 1.2 }}\n textValue={item.label}\n >\n {renderTag(item)}\n {!disabled && (\n <Button\n aria-label={`Remove ${item.label}`}\n className=\"btn-close btn-close-sm ms-1 p-0 border-0 bg-transparent\"\n slot=\"remove\"\n style={{ fontSize: '0.6rem' }}\n />\n )}\n </Tag>\n ))}\n </TagList>\n </TagGroup>\n )}\n\n <div className=\"flex-grow-1 d-flex align-items-center\">\n <Input\n className=\"border-0 flex-grow-1 bg-transparent\"\n id={id}\n placeholder={selectedItems.length === 0 ? placeholder : ''}\n style={{ outline: 'none', minWidth: '60px' }}\n />\n <Button aria-label=\"Show suggestions\" className=\"border-0 bg-transparent p-0 ms-auto\">\n <i\n aria-hidden=\"true\"\n className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}\n />\n </Button>\n </div>\n </Group>\n\n {description && (\n <Text className=\"form-text text-muted\" slot=\"description\">\n {description}\n </Text>\n )}\n\n <FieldError className=\"invalid-feedback d-block\">{errorMessage}</FieldError>\n\n <Popover\n className=\"dropdown-menu show py-0 overflow-auto\"\n offset={2}\n style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}\n >\n <ListBox\n className=\"list-unstyled m-0\"\n items={filteredItems}\n renderEmptyState={() => (\n <div className=\"dropdown-item text-muted\">No options found</div>\n )}\n >\n {(item) => {\n const isSelected = value.includes(item.id);\n return (\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.label}\n >\n <input\n checked={isSelected}\n className=\"form-check-input m-0\"\n tabIndex={-1}\n type=\"checkbox\"\n readOnly\n />\n <div className=\"flex-grow-1\">{renderItem(item)}</div>\n </ListBoxItem>\n );\n }}\n </ListBox>\n </Popover>\n </AriaComboBox>\n </div>\n );\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTr
11
11
  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
+ export { ComboBox, TagPicker, type ComboBoxItem, type ComboBoxProps, type TagPickerProps, } from './components/ComboBox.js';
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -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","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';\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","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"]}
package/dist/index.js CHANGED
@@ -12,4 +12,5 @@ export { OverlayTrigger } from './components/OverlayTrigger.js';
12
12
  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
+ export { ComboBox, TagPicker, } from './components/ComboBox.js';
15
16
  //# 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","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';\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","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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/ui",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,25 +20,27 @@
20
20
  "test": "vitest run --coverage"
21
21
  },
22
22
  "dependencies": {
23
- "@prairielearn/browser-utils": "^2.6.2",
23
+ "@prairielearn/browser-utils": "^2.6.3",
24
24
  "@tanstack/react-table": "^8.21.3",
25
25
  "@tanstack/react-virtual": "^3.13.18",
26
26
  "@tanstack/table-core": "^8.21.3",
27
- "@types/react": "^19.2.8",
27
+ "@types/react": "^19.2.10",
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "clsx": "^2.1.1",
30
- "nuqs": "^2.8.6",
31
- "react": "^19.2.3",
30
+ "nuqs": "^2.8.7",
31
+ "react": "^19.2.4",
32
+ "react-aria": "^3.45.0",
33
+ "react-aria-components": "^1.14.0",
32
34
  "react-bootstrap": "3.0.0-beta.5",
33
- "react-dom": "^19.2.3",
35
+ "react-dom": "^19.2.4",
34
36
  "use-debounce": "^10.1.0"
35
37
  },
36
38
  "devDependencies": {
37
- "@prairielearn/tsconfig": "^0.0.0",
39
+ "@prairielearn/tsconfig": "^2.0.0",
38
40
  "@types/node": "^24.10.9",
39
- "@typescript/native-preview": "^7.0.0-dev.20260106.1",
41
+ "@typescript/native-preview": "^7.0.0-dev.20260130.1",
40
42
  "typescript": "^5.9.3",
41
43
  "typescript-cp": "^0.1.9",
42
- "vitest": "^4.0.17"
44
+ "vitest": "^4.0.18"
43
45
  }
44
46
  }
@@ -0,0 +1,414 @@
1
+ import clsx from 'clsx';
2
+ import { type Key, type ReactNode, useMemo, useState } from 'react';
3
+ import { useFilter } from 'react-aria';
4
+ import {
5
+ ComboBox as AriaComboBox,
6
+ type ComboBoxProps as AriaComboBoxProps,
7
+ Button,
8
+ FieldError,
9
+ Group,
10
+ Input,
11
+ Label,
12
+ ListBox,
13
+ ListBoxItem,
14
+ Popover,
15
+ Tag,
16
+ TagGroup,
17
+ TagList,
18
+ Text,
19
+ } from 'react-aria-components';
20
+
21
+ /** An item in the ComboBox or TagPicker dropdown. */
22
+ export interface ComboBoxItem<T = void> {
23
+ id: string;
24
+ label: string;
25
+ /** Custom data passed to renderItem. */
26
+ data?: T;
27
+ /** Text used for filtering (defaults to label). */
28
+ searchableText?: string;
29
+ }
30
+
31
+ type ManagedAriaProps =
32
+ | 'children'
33
+ | 'items'
34
+ | 'selectedKey'
35
+ | 'defaultSelectedKey'
36
+ | 'onSelectionChange'
37
+ | 'inputValue'
38
+ | 'defaultInputValue'
39
+ | 'onInputChange'
40
+ | 'onOpenChange'
41
+ | 'menuTrigger'
42
+ | 'allowsEmptyCollection'
43
+ | 'isDisabled'
44
+ | 'isInvalid';
45
+
46
+ export interface ComboBoxProps<T = void> extends Omit<
47
+ AriaComboBoxProps<ComboBoxItem<T>>,
48
+ ManagedAriaProps
49
+ > {
50
+ items: ComboBoxItem<T>[];
51
+ value: string | null;
52
+ onChange: (value: string | null) => void;
53
+ placeholder?: string;
54
+ disabled?: boolean;
55
+ /** Name for hidden form input. */
56
+ name?: string;
57
+ /** ID for the input element. */
58
+ id?: string;
59
+ label?: string;
60
+ description?: string;
61
+ errorMessage?: string;
62
+ renderItem?: (item: ComboBoxItem<T>) => ReactNode;
63
+ }
64
+
65
+ export interface TagPickerProps<T = void> extends Omit<
66
+ AriaComboBoxProps<ComboBoxItem<T>>,
67
+ ManagedAriaProps
68
+ > {
69
+ items: ComboBoxItem<T>[];
70
+ value: string[];
71
+ onChange: (value: string[]) => void;
72
+ placeholder?: string;
73
+ disabled?: boolean;
74
+ /** Name for hidden form inputs. */
75
+ name?: string;
76
+ /** ID for the input element. */
77
+ id?: string;
78
+ label?: string;
79
+ description?: string;
80
+ errorMessage?: string;
81
+ renderItem?: (item: ComboBoxItem<T>) => ReactNode;
82
+ renderTag?: (item: ComboBoxItem<T>) => ReactNode;
83
+ }
84
+
85
+ function defaultRenderItem<T>(item: ComboBoxItem<T>) {
86
+ return <span>{item.label}</span>;
87
+ }
88
+
89
+ function defaultRenderTag<T>(item: ComboBoxItem<T>) {
90
+ return <span className="badge bg-secondary">{item.label}</span>;
91
+ }
92
+
93
+ /**
94
+ * Single-selection combobox with filtering.
95
+ */
96
+ export function ComboBox<T = void>({
97
+ items,
98
+ value,
99
+ onChange,
100
+ placeholder = 'Select...',
101
+ disabled = false,
102
+ name,
103
+ id,
104
+ label,
105
+ description,
106
+ errorMessage,
107
+ renderItem = defaultRenderItem,
108
+ ...props
109
+ }: ComboBoxProps<T>) {
110
+ const { contains } = useFilter({ sensitivity: 'base' });
111
+ const [isOpen, setIsOpen] = useState(false);
112
+ const [filterText, setFilterText] = useState('');
113
+
114
+ const selectedItem = useMemo(
115
+ () => items.find((item) => item.id === value) ?? null,
116
+ [items, value],
117
+ );
118
+
119
+ // Input value is derived: show filter text when open, selected label when closed
120
+ const inputValue = isOpen ? filterText : (selectedItem?.label ?? '');
121
+
122
+ const filteredItems = useMemo(() => {
123
+ if (!inputValue.trim()) return items;
124
+ return items.filter((item) => {
125
+ const searchable = item.searchableText ?? item.label;
126
+ return contains(searchable, inputValue);
127
+ });
128
+ }, [items, inputValue, contains]);
129
+
130
+ const handleSelectionChange = (key: Key | null) => {
131
+ const stringKey = typeof key === 'string' ? key : null;
132
+ const newSelectedItem = stringKey ? items.find((item) => item.id === stringKey) : null;
133
+ onChange(stringKey);
134
+ // Set filter text to new label for immediate display before props update
135
+ setFilterText(newSelectedItem?.label ?? '');
136
+ };
137
+
138
+ const handleInputChange = (inputVal: string) => {
139
+ setFilterText(inputVal);
140
+ if (inputVal === '' && value !== null) {
141
+ onChange(null);
142
+ }
143
+ };
144
+
145
+ const handleOpenChange = (open: boolean, trigger?: 'focus' | 'input' | 'manual') => {
146
+ setIsOpen(open);
147
+ // Initialize filter text to selected label when opening via focus
148
+ if (open && trigger === 'focus') {
149
+ setFilterText(selectedItem?.label ?? '');
150
+ }
151
+ };
152
+
153
+ return (
154
+ <div className="position-relative">
155
+ {name && <input name={name} type="hidden" value={value ?? ''} />}
156
+
157
+ <AriaComboBox
158
+ {...props}
159
+ selectedKey={value}
160
+ inputValue={inputValue}
161
+ isDisabled={disabled}
162
+ isInvalid={!!errorMessage}
163
+ menuTrigger="focus"
164
+ allowsEmptyCollection
165
+ onSelectionChange={handleSelectionChange}
166
+ onInputChange={handleInputChange}
167
+ onOpenChange={handleOpenChange}
168
+ >
169
+ {label && <Label className="form-label">{label}</Label>}
170
+
171
+ <Group
172
+ className={clsx(
173
+ 'form-control d-flex align-items-center gap-1',
174
+ disabled && 'bg-body-secondary',
175
+ isOpen && 'border-primary shadow-sm',
176
+ errorMessage && 'is-invalid',
177
+ )}
178
+ style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}
179
+ >
180
+ <Input
181
+ className="border-0 flex-grow-1 bg-transparent"
182
+ id={id}
183
+ placeholder={placeholder}
184
+ style={{ outline: 'none' }}
185
+ />
186
+ <Button aria-label="Show suggestions" className="border-0 bg-transparent p-0 ms-auto">
187
+ <i
188
+ aria-hidden="true"
189
+ className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}
190
+ />
191
+ </Button>
192
+ </Group>
193
+
194
+ {description && (
195
+ <Text className="form-text text-muted" slot="description">
196
+ {description}
197
+ </Text>
198
+ )}
199
+
200
+ <FieldError className="invalid-feedback d-block">{errorMessage}</FieldError>
201
+
202
+ <Popover
203
+ className="dropdown-menu show py-0 overflow-auto"
204
+ offset={2}
205
+ style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}
206
+ >
207
+ <ListBox
208
+ className="list-unstyled m-0"
209
+ items={filteredItems}
210
+ renderEmptyState={() => (
211
+ <div className="dropdown-item text-muted">No options found</div>
212
+ )}
213
+ >
214
+ {(item) => (
215
+ <ListBoxItem
216
+ id={item.id}
217
+ className={({ isFocused, isSelected }) =>
218
+ clsx(
219
+ 'dropdown-item d-flex align-items-center gap-2',
220
+ isFocused && 'active',
221
+ isSelected && 'fw-semibold',
222
+ )
223
+ }
224
+ style={{ cursor: 'pointer' }}
225
+ textValue={item.label}
226
+ >
227
+ <span className="flex-grow-1">{renderItem(item)}</span>
228
+ </ListBoxItem>
229
+ )}
230
+ </ListBox>
231
+ </Popover>
232
+ </AriaComboBox>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Multi-selection combobox with removable tags.
239
+ */
240
+ export function TagPicker<T = void>({
241
+ items,
242
+ value,
243
+ onChange,
244
+ placeholder = 'Select...',
245
+ disabled = false,
246
+ name,
247
+ id,
248
+ label,
249
+ description,
250
+ errorMessage,
251
+ renderItem = defaultRenderItem,
252
+ renderTag = defaultRenderTag,
253
+ ...props
254
+ }: TagPickerProps<T>) {
255
+ const { contains } = useFilter({ sensitivity: 'base' });
256
+ const [inputValue, setInputValue] = useState('');
257
+ const [isOpen, setIsOpen] = useState(false);
258
+
259
+ const selectedItems = useMemo(
260
+ () => items.filter((item) => value.includes(item.id)),
261
+ [items, value],
262
+ );
263
+
264
+ const filteredItems = useMemo(() => {
265
+ if (!inputValue.trim()) return items;
266
+ return items.filter((item) => {
267
+ const searchable = item.searchableText ?? item.label;
268
+ return contains(searchable, inputValue);
269
+ });
270
+ }, [items, inputValue, contains]);
271
+
272
+ const handleRemoveTag = (keys: Set<Key>) => {
273
+ const newValue = value.filter((v) => !keys.has(v));
274
+ onChange(newValue);
275
+ };
276
+
277
+ const handleSelect = (key: Key | null) => {
278
+ const itemId = typeof key === 'string' ? key : null;
279
+ if (!itemId) return;
280
+ if (value.includes(itemId)) {
281
+ onChange(value.filter((v) => v !== itemId));
282
+ } else {
283
+ onChange([...value, itemId]);
284
+ }
285
+ setInputValue('');
286
+ };
287
+
288
+ return (
289
+ <div className="position-relative">
290
+ {name &&
291
+ (selectedItems.length > 0 ? (
292
+ selectedItems.map((item) => (
293
+ <input key={item.id} name={name} type="hidden" value={item.id} />
294
+ ))
295
+ ) : (
296
+ <input name={name} type="hidden" value="" />
297
+ ))}
298
+
299
+ <AriaComboBox
300
+ {...props}
301
+ inputValue={inputValue}
302
+ isDisabled={disabled}
303
+ isInvalid={!!errorMessage}
304
+ menuTrigger="focus"
305
+ selectedKey={null}
306
+ allowsEmptyCollection
307
+ onInputChange={setInputValue}
308
+ onOpenChange={setIsOpen}
309
+ onSelectionChange={handleSelect}
310
+ >
311
+ {label && <Label className="form-label">{label}</Label>}
312
+
313
+ <Group
314
+ className={clsx(
315
+ 'form-control d-flex flex-wrap align-items-center gap-1',
316
+ disabled && 'bg-body-secondary',
317
+ isOpen && 'border-primary shadow-sm',
318
+ errorMessage && 'is-invalid',
319
+ )}
320
+ style={{ minHeight: '38px', cursor: disabled ? 'not-allowed' : 'text' }}
321
+ >
322
+ {selectedItems.length > 0 && (
323
+ <TagGroup
324
+ aria-label="Selected items"
325
+ onRemove={!disabled ? handleRemoveTag : undefined}
326
+ >
327
+ <TagList>
328
+ {selectedItems.map((item) => (
329
+ <Tag
330
+ key={item.id}
331
+ id={item.id}
332
+ className="d-inline-flex align-items-center"
333
+ style={{ lineHeight: 1.2 }}
334
+ textValue={item.label}
335
+ >
336
+ {renderTag(item)}
337
+ {!disabled && (
338
+ <Button
339
+ aria-label={`Remove ${item.label}`}
340
+ className="btn-close btn-close-sm ms-1 p-0 border-0 bg-transparent"
341
+ slot="remove"
342
+ style={{ fontSize: '0.6rem' }}
343
+ />
344
+ )}
345
+ </Tag>
346
+ ))}
347
+ </TagList>
348
+ </TagGroup>
349
+ )}
350
+
351
+ <div className="flex-grow-1 d-flex align-items-center">
352
+ <Input
353
+ className="border-0 flex-grow-1 bg-transparent"
354
+ id={id}
355
+ placeholder={selectedItems.length === 0 ? placeholder : ''}
356
+ style={{ outline: 'none', minWidth: '60px' }}
357
+ />
358
+ <Button aria-label="Show suggestions" className="border-0 bg-transparent p-0 ms-auto">
359
+ <i
360
+ aria-hidden="true"
361
+ className={clsx('bi', isOpen ? 'bi-chevron-up' : 'bi-chevron-down', 'text-muted')}
362
+ />
363
+ </Button>
364
+ </div>
365
+ </Group>
366
+
367
+ {description && (
368
+ <Text className="form-text text-muted" slot="description">
369
+ {description}
370
+ </Text>
371
+ )}
372
+
373
+ <FieldError className="invalid-feedback d-block">{errorMessage}</FieldError>
374
+
375
+ <Popover
376
+ className="dropdown-menu show py-0 overflow-auto"
377
+ offset={2}
378
+ style={{ maxHeight: '300px', width: 'var(--trigger-width)' }}
379
+ >
380
+ <ListBox
381
+ className="list-unstyled m-0"
382
+ items={filteredItems}
383
+ renderEmptyState={() => (
384
+ <div className="dropdown-item text-muted">No options found</div>
385
+ )}
386
+ >
387
+ {(item) => {
388
+ const isSelected = value.includes(item.id);
389
+ return (
390
+ <ListBoxItem
391
+ id={item.id}
392
+ className={({ isFocused }) =>
393
+ clsx('dropdown-item d-flex align-items-center gap-2', isFocused && 'active')
394
+ }
395
+ style={{ cursor: 'pointer' }}
396
+ textValue={item.label}
397
+ >
398
+ <input
399
+ checked={isSelected}
400
+ className="form-check-input m-0"
401
+ tabIndex={-1}
402
+ type="checkbox"
403
+ readOnly
404
+ />
405
+ <div className="flex-grow-1">{renderItem(item)}</div>
406
+ </ListBoxItem>
407
+ );
408
+ }}
409
+ </ListBox>
410
+ </Popover>
411
+ </AriaComboBox>
412
+ </div>
413
+ );
414
+ }
package/src/index.ts CHANGED
@@ -32,3 +32,10 @@ export {
32
32
  } from './components/nuqs.js';
33
33
 
34
34
  export { useModalState } from './hooks/use-modal-state.js';
35
+ export {
36
+ ComboBox,
37
+ TagPicker,
38
+ type ComboBoxItem,
39
+ type ComboBoxProps,
40
+ type TagPickerProps,
41
+ } from './components/ComboBox.js';