nextui icon indicating copy to clipboard operation
nextui copied to clipboard

[Feature Request] Autocomplete to work with multi-select and chips

Open 0xhckr opened this issue 1 year ago • 8 comments

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: image

0xhckr avatar Jan 30 '24 18:01 0xhckr

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; className: string; elementKey: string; haveSearch: boolean | undefined; isRequired: boolean | undefined; isLoading: boolean | undefined; isDisabled: boolean | undefined; onSelectionChange: ((keys: Set) => void) | undefined; }

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(false);

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([]); if (newValue) { if (Array.isArray(newValue)) { newValue.forEach((o) => { selectedKeys.add(o.value.toString()); }); } else { selectedKeys.add((newValue as SingleValue<Item>)!.value.toString()); } } onSelectionChange(selectedKeys); } }

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(allValues); setSelectedOptions(items); if (onSelectionChange) { onSelectionChange(selectedKeys); } };

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; }

QP-MRMousavi avatar May 26 '24 12:05 QP-MRMousavi

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}
    />
  )
}

impact-ls avatar Jul 26 '24 19:07 impact-ls

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

JorgeMantillaHernandez avatar Aug 01 '24 00:08 JorgeMantillaHernandez

+1 Need this feature

ali-shafi-hff avatar Aug 14 '24 08:08 ali-shafi-hff

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.

atularora82 avatar Aug 20 '24 13:08 atularora82

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

riczudaniel avatar Aug 26 '24 15:08 riczudaniel

+1

conduongtong avatar Aug 27 '24 13:08 conduongtong

I end up use React-Select. I need to use Autocomplete with form. Hope to have Multi select mode in Autocomplete soon...

VichetN avatar Aug 28 '24 04:08 VichetN

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:

image

matteogilioli avatar Dec 22 '24 22:12 matteogilioli

+1

eng-ahmad-sameer avatar Jan 12 '25 18:01 eng-ahmad-sameer