[Feature Request] Autocomplete to work with multi-select and chips
Is your feature request related to a problem? Please describe.
I like simpler UIs that functionally support complex use-cases. One such use case is the ability to create a searchable text field that "queues" up items for some request to complete later on. An example being selecting various users to delete or attaching/creating tags for a blog post.
Describe the solution you'd like
I would like for the Autocomplete component to allow adding the ability to select multiple entries and also render them as chips. Additionally it would be nice if it also had the ability to create items on the fly (referring to my earlier example - creating a tag you should be able to very easily without employing a hacky method be allowed to type an autocomplete value and hit a button in the dropdown saying "add")
Describe alternatives you've considered
Currently the only one that works for my use case is the Select component with the multi-select ability + using chips. This however does not allow for the ability to dynamically add an entry without employing a hacky solution that adds bulk to the UI.
Screenshots or Videos
Material UI currently supports this and here is an example:
i wrote this code to handle it with Select input using "react-select" library and a similar design to NextUi: import React, { useState, useCallback, ReactNode, useEffect } from "react"; import Select, { ActionMeta, MultiValue, SingleValue, components, } from "react-select";
import { Chip, SelectionMode } from "@nextui-org/react";
type Item = { value: number; label: string; searchingValue: string; subHeader: ReactNode; };
interface Props {
items: Item[];
label: string;
selectionMode: SelectionMode;
placeholder: string;
selectedKeys: Set
const MultiSelectInputWithSearch = ({ items, label, selectionMode, placeholder, selectedKeys, className, elementKey, onSelectionChange, haveSearch = false, isLoading = false, isDisabled = false, isRequired = false, }: Props) => { const [selectedOptions, setSelectedOptions] = useState< MultiValue<Item> | SingleValue<Item> | null
(null);
const [isDropDownOpen, setIsDropDownOpen] = useState
useEffect(() => { if ( selectedKeys.size === 0 && Array.isArray(selectedOptions) && selectedOptions.length !== 0 ) { setSelectedOptions(null); } }, [selectedKeys]);
const handleChange = useCallback( ( newValue: MultiValue<Item> | SingleValue<Item> | null, actionMeta: ActionMeta<Item> ) => { setValueOfSelections(newValue); setIsDropDownOpen(true); }, [] );
function deleteSelectedItemByValue(value: number) { const allNewSelectedItems = (selectedOptions as MultiValue<Item>).filter( (item) => item.value !== value ); setValueOfSelections(allNewSelectedItems); }
function setValueOfSelections(
newValue: MultiValue<Item> | SingleValue<Item> | null
) {
setSelectedOptions(newValue);
if (onSelectionChange) {
const selectedKeys = new Set
const formatOptionLabel = ({ value, label, subHeader }: Item) => ( <div className="flex flex-col"> {label} {subHeader && ( <span className="text-gray-500 dark:text-gray-200">{subHeader} )} );
const MultiValueContainer = (props: any) => (
<components.MultiValueContainer {...props}>
<Chip
color="default"
variant="bordered"
key={${elementKey}-${props.data.value}}
onClose={() => deleteSelectedItemByValue(props.data.value)}
>
{props.data.label}
</Chip>
</components.MultiValueContainer>
);
const handleSelectAll = () => {
const allValues = items.map((item) => item.value.toString());
const selectedKeys = new Set
return (
<div className="flex flex-col w-full gap-2">
<label
htmlFor={SELECT-${elementKey}}
className="flex flex-row justify-between"
>
{label}
{!((selectedOptions as MultiValue<Item>)?.length === items.length) && (
<Chip onClick={handleSelectAll}>Select All</Chip>
)}
<Select
key={SELECT-${elementKey}}
isClearable={true}
onChange={handleChange}
onMenuClose={() => setIsDropDownOpen(false)} // Close dropdown when focus is lost
isMulti={selectionMode === "multiple"}
isSearchable={haveSearch}
isLoading={isLoading}
required={isRequired}
isDisabled={isDisabled}
placeholder={placeholder}
value={selectedOptions}
options={items}
formatOptionLabel={formatOptionLabel}
getOptionLabel={(option) => option.label}
getOptionValue={(option) => option.value.toString()}
components={{ MultiValueContainer }}
menuIsOpen={isDropDownOpen} // Control dropdown visibility
onMenuOpen={() => {
setIsDropDownOpen(true);
}}
className="nextui-select"
classNamePrefix="nextui-select"
/>
);
};
export default MultiSelectInputWithSearch;
and here is the CSS file: /* Light Mode */ .nextui-select__control { @apply bg-white border border-gray-300 rounded-md shadow-sm px-4 py-2 text-sm text-gray-700; }
.nextui-select__single-value { @apply text-gray-700; }
.nextui-select__multi-value { background-color: unset !important; }
.nextui-select__indicator { @apply text-gray-400; }
.nextui-select__dropdown-indicator { @apply text-gray-400; }
.nextui-select__menu { @apply border border-gray-300 rounded-md shadow-lg; background-color: #ffffff !important; }
.nextui-select__option { @apply px-4 py-2 text-sm text-gray-700 cursor-pointer; }
.nextui-select__option--is-focused { @apply bg-gray-100; }
.nextui-select__option--is-selected { @apply bg-blue-500 text-white; } .nextui-select__input-container { @apply text-gray-800; }
/* Dark Mode */ .dark .nextui-select__control { @apply bg-gray-800 border border-gray-700 rounded-md shadow-sm px-4 py-2 text-sm text-white; }
.dark .nextui-select__single-value { @apply text-white; }
.dark .nextui-select__indicator { @apply text-gray-400; }
.dark .nextui-select__dropdown-indicator { @apply text-gray-400; }
.dark .nextui-select__menu { @apply border border-gray-700 rounded-md shadow-lg; background-color: #18181b !important; }
.dark .nextui-select__option { @apply px-4 py-2 text-sm text-white cursor-pointer; }
.dark .nextui-select__option--is-focused { @apply bg-gray-700; }
.dark .nextui-select__option--is-selected { @apply bg-blue-500 text-white; } .dark .nextui-select__multi-value { background-color: unset !important; } .dark .nextui-select__input-container { @apply text-gray-50; }
I have a simple version without external libraries that worked well for us, display selected items in endContent (ChipGroup is a row div with chips):
'use client'
import { useState } from 'react'
import { Autocomplete, ChipGroup } from '@ui/components'
export interface AutocompleteWithChipsProps {
items: string[]
label: string
}
export function AutocompleteWithChips({
items: defaultItem,
label,
}: AutocompleteWithChipsProps) {
const [items, setItems] = useState<string[]>(defaultItem)
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [inputValue, setInputValue] = useState('')
const handleSelectItem = (item: string) => {
if (!selectedItems.includes(item)) {
item && setSelectedItems([...selectedItems, item])
} else {
setSelectedItems(selectedItems.filter((f) => f !== item))
}
setInputValue('')
}
const filteredItems = inputValue
? [
...items.filter((item) =>
item.toLowerCase().includes(inputValue.toLowerCase()),
),
`Add: ${inputValue}`,
]
: items
const handleRemoveItem = (item: string) => {
setSelectedItems(
selectedItems.filter((selectedItem) => selectedItem !== item),
)
}
return (
<Autocomplete
items={filteredItems}
label={label}
classNames={{
endContentWrapper: 'w-full',
}}
endContent={
<ChipGroup
className='w-full justify-end'
items={selectedItems.map((item) => ({
key: item,
label: item,
className: 'cursor-pointer',
variant: 'dot',
onClick: () => handleRemoveItem(item),
}))}
/>
}
onSelectionChange={(item) => {
if (item && typeof item === 'string') {
if (item.startsWith('Add: ')) {
if (!items.includes(inputValue)) {
setItems([...items, inputValue])
}
handleSelectItem(inputValue)
} else {
handleSelectItem(item)
}
}
}}
selectedKey={''}
inputValue={inputValue}
onInputChange={setInputValue}
/>
)
}
I have a simple version without external libraries that worked well for us, display selected items in
endContent(ChipGroup is a row div with chips):'use client' import { useState } from 'react' import { Autocomplete, ChipGroup } from '@ui/components' export interface AutocompleteWithChipsProps { items: string[] label: string } export function AutocompleteWithChips({ items: defaultItem, label, }: AutocompleteWithChipsProps) { const [items, setItems] = useState<string[]>(defaultItem) const [selectedItems, setSelectedItems] = useState<string[]>([]) const [inputValue, setInputValue] = useState('') const handleSelectItem = (item: string) => { if (!selectedItems.includes(item)) { item && setSelectedItems([...selectedItems, item]) } else { setSelectedItems(selectedItems.filter((f) => f !== item)) } setInputValue('') } const filteredItems = inputValue ? [ ...items.filter((item) => item.toLowerCase().includes(inputValue.toLowerCase()), ), `Add: ${inputValue}`, ] : items const handleRemoveItem = (item: string) => { setSelectedItems( selectedItems.filter((selectedItem) => selectedItem !== item), ) } return ( <Autocomplete items={filteredItems} label={label} classNames={{ endContentWrapper: 'w-full', }} endContent={ <ChipGroup className='w-full justify-end' items={selectedItems.map((item) => ({ key: item, label: item, className: 'cursor-pointer', variant: 'dot', onClick: () => handleRemoveItem(item), }))} /> } onSelectionChange={(item) => { if (item && typeof item === 'string') { if (item.startsWith('Add: ')) { if (!items.includes(inputValue)) { setItems([...items, inputValue]) } handleSelectItem(inputValue) } else { handleSelectItem(item) } } }} selectedKey={''} inputValue={inputValue} onInputChange={setInputValue} /> ) }
thanks! can you share the code of ChipGroup please
+1 Need this feature
I have a simple version without external libraries that worked well for us, display selected items in
endContent(ChipGroup is a row div with chips):'use client' import { useState } from 'react' import { Autocomplete, ChipGroup } from '@ui/components' export interface AutocompleteWithChipsProps { items: string[] label: string } export function AutocompleteWithChips({ items: defaultItem, label, }: AutocompleteWithChipsProps) { const [items, setItems] = useState<string[]>(defaultItem) const [selectedItems, setSelectedItems] = useState<string[]>([]) const [inputValue, setInputValue] = useState('') const handleSelectItem = (item: string) => { if (!selectedItems.includes(item)) { item && setSelectedItems([...selectedItems, item]) } else { setSelectedItems(selectedItems.filter((f) => f !== item)) } setInputValue('') } const filteredItems = inputValue ? [ ...items.filter((item) => item.toLowerCase().includes(inputValue.toLowerCase()), ), `Add: ${inputValue}`, ] : items const handleRemoveItem = (item: string) => { setSelectedItems( selectedItems.filter((selectedItem) => selectedItem !== item), ) } return ( <Autocomplete items={filteredItems} label={label} classNames={{ endContentWrapper: 'w-full', }} endContent={ <ChipGroup className='w-full justify-end' items={selectedItems.map((item) => ({ key: item, label: item, className: 'cursor-pointer', variant: 'dot', onClick: () => handleRemoveItem(item), }))} /> } onSelectionChange={(item) => { if (item && typeof item === 'string') { if (item.startsWith('Add: ')) { if (!items.includes(inputValue)) { setItems([...items, inputValue]) } handleSelectItem(inputValue) } else { handleSelectItem(item) } } }} selectedKey={''} inputValue={inputValue} onInputChange={setInputValue} /> ) }
Kindly share the ChipGroup code.
I have just implemented a new solution for us regarding multiselect with the option to search for the listed elements. An array is set in the props (it can also be set directly, but in our case it was not convenient.
Feel free to use and wish you a good coding 🤲🏼
import { useState } from 'react'
import { Autocomplete, AutocompleteItem, Chip } from "@nextui-org/react";
import { X, Check } from 'lucide-react';
const MultiselectSearch = ({array}) => {
const [selectedItems, setSelectedItems] = useState([])
const handleSelect = (item) => {
if (!selectedItems.includes(item)) {
setSelectedItems([...selectedItems, item])
} else {
setSelectedItems(selectedItems.filter(selection => selection !== item))
}
}
const handleDeleteSelection = (item) => {
setSelectedItems(selectedItems.filter(selection => selection !== item))
}
return (
<div>
<Autocomplete
className="w-96"
selectedKey={''}>
{array.map((item, index) => (
<AutocompleteItem
key={index}
value={item}
onClick={() => handleSelect(item)}
endContent={
setSelectedItems.includes(item) && (
<Check size={16} className="mr-2 text-green-500" />
)
}
>
{item}
</AutocompleteItem>
))}
</Autocomplete>
<div className="flex mt-2 w-96 flex-wrap">
{setSelectedItems.map((item) => (
<Chip
color={"primary"}
className="mr-2 mt-2"
endContent={<X
size={14}
className="mr-1 cursor-pointer"
onClick={() => handleDeleteSelection(item)}
/>}>
{item}
</Chip>
))}
</div>
</div>
)
}
export default MultiselectSearch
+1
I end up use React-Select. I need to use Autocomplete with form. Hope to have Multi select mode in Autocomplete soon...
Now that Autocomplete supports virtualization, I have decided to abandon react-select. I created an autocomplete component with chips inside to manage the elements, inspired just by react-select. I created it to manage the array passed externally using the primaryKey specified in props for the identifier, and a name attribute.
The only little problem I couldn't solve is not moving the focus to the first element after you select an element.
I hope someone finds it useful.
import { useCallback, useEffect, useRef, useState } from "react";
import { Autocomplete, AutocompleteItem, Button, Chip, cn } from "@nextui-org/react";
import { IconCheck, IconX } from "@tabler/icons-react";
export const AutocompleteMultiple = ({ items = [], selectedKeys = [], onSelectionChange = () => null, primaryKey = "id", ...props }) => {
const [inputValue, setInputValue] = useState("");
const inputRef = useRef(null);
// Manage the selection of the items
const handleSelectItem = (id) => {
if (!id) return;
if (!selectedKeys.map((key) => key.toString()).includes(id.toString())) {
onSelectionChange([...selectedKeys, id]);
} else handleRemoveItem(id);
setInputValue("");
};
const filteredItems = inputValue ? items.filter((item) => item.name.toLowerCase().includes(inputValue.toLowerCase())) : items;
const handleRemoveItem = (id) => onSelectionChange(selectedKeys.filter((i) => i.toString() !== id.toString()));
// Manage the filled-within state for the label
const selectedKeysRef = useRef(selectedKeys);
const changeFilledWithin = useCallback((filledWithin) => {
const isFilledWithin = !!(filledWithin === "true" || inputRef?.current?.getAttribute("data-filled-within") === "true");
const filled = isFilledWithin || selectedKeysRef.current.length;
inputRef?.current?.parentElement?.parentElement?.parentElement?.setAttribute("data-filled-within", !!filled ? "true" : "false");
}, []);
useEffect(() => {
selectedKeysRef.current = selectedKeys;
changeFilledWithin();
}, [selectedKeys]);
useEffect(() => {
const handleMutation = (mutationsList) => {
for (let mutation of mutationsList)
if (mutation.type === "attributes" && mutation.attributeName === "data-filled-within")
changeFilledWithin(mutation?.target?.getAttribute("data-filled-within"));
};
if (inputRef.current) {
const observer = new MutationObserver(handleMutation);
observer.observe(inputRef.current, { attributes: true });
return () => observer.disconnect();
}
}, []);
return (
<Autocomplete
ref={inputRef}
classNames={{
base: "overflow-hidden",
endContentWrapper: "absolute top-[0.4px] right-3",
}}
startContent={selectedKeys.map((id) => (
<Chip
key={id.toString()}
classNames={{
base: "bg-white rounded-lg min-w-0",
content: "truncate",
}}
endContent={
<IconX className="rounded-full hover:bg-default/40 p-1 cursor-pointer size-5 mr-1" onClick={() => handleRemoveItem(id)} />
}
>
{items.find((item) => item[primaryKey].toString() === id.toString())?.name}
</Chip>
))}
endContent={
<Button
variant="light"
isIconOnly
size="sm"
className={cn("rounded-full opacity-0 group-data-[hover=true]:opacity-100 data-[hover=true]:bg-default/40", {
hidden: !selectedKeys.length,
})}
onPress={() => onSelectionChange([])}
>
<IconX className="size-4" />
</Button>
}
selectedKey={null}
isClearable={false}
onSelectionChange={(id) => handleSelectItem(id)}
inputValue={inputValue}
onInputChange={setInputValue}
inputProps={{
classNames: {
label: "mt-2.5 group-data-[filled-within=true]:translate-y-0 group-data-[filled-within=true]:mt-0",
inputWrapper: cn(" block", {
"min-h-8": selectedKeys.length === 0,
"h-auto": selectedKeys.length > 0,
}),
innerWrapper: cn("flex flex-wrap gap-1 h-auto max-w-[calc(100%-4rem)]", {
"mt-3 -ml-1.5": selectedKeys.length === 0,
"mt-6": selectedKeys.length > 0,
}),
input: "w-20 h-7",
},
}}
{...props}
>
{filteredItems.map((item) => (
<AutocompleteItem
key={item[primaryKey]}
textValue={item.name}
endContent={selectedKeys.map((key) => key.toString()).includes(item[primaryKey].toString()) && <IconCheck className="size-4" />}
>
{item.name}
</AutocompleteItem>
))}
</Autocomplete>
);
};
Example:
+1