@prairielearn/ui 3.0.0 → 3.1.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 +6 -0
- package/README.md +24 -0
- package/dist/components/ComboBox.d.ts +53 -0
- package/dist/components/ComboBox.d.ts.map +1 -0
- package/dist/components/ComboBox.js +101 -0
- package/dist/components/ComboBox.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 +3 -1
- package/src/components/ComboBox.tsx +414 -0
- package/src/index.ts +7 -0
package/CHANGELOG.md
CHANGED
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
|
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","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.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"clsx": "^2.1.1",
|
|
30
30
|
"nuqs": "^2.8.6",
|
|
31
31
|
"react": "^19.2.3",
|
|
32
|
+
"react-aria": "^3.45.0",
|
|
33
|
+
"react-aria-components": "^1.7.1",
|
|
32
34
|
"react-bootstrap": "3.0.0-beta.5",
|
|
33
35
|
"react-dom": "^19.2.3",
|
|
34
36
|
"use-debounce": "^10.1.0"
|
|
@@ -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