ui icon indicating copy to clipboard operation
ui copied to clipboard

Combobox with remote search example

Open corn-flour opened this issue 1 year ago • 10 comments

I think an example of using combobox which calls a fetch function to query results will be very helpful. This is a very common usage for a combobox since you don't always have the entire list of items ready for cmdk to filter (for example a search box to find users where the user list is very large and there is an endpoint to search user by query). It is not exactly the most straightforward thing to write since we need to stop using the filter function of cmdk.

I am open to create a PR if needed.

corn-flour avatar Jul 07 '23 07:07 corn-flour

+1

Would love to have a canonical example for remote search with typeahead.

It seems like a common enough use case to merit at least some pointers.

iljapanic avatar Jul 07 '23 21:07 iljapanic

+1 Need this too!

tika avatar Aug 18 '23 22:08 tika

+1 Working on this but new data list doesn't display

csulit avatar Sep 23 '23 09:09 csulit

+1 🙏🏻

lililiardet avatar Oct 16 '23 09:10 lililiardet

@corn-flour @iljapanic @tika @csulit @lililiardet you can look at the example in #1794 , at least until it is not merged

import * as React from "react"

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList
} from "@/registry/default/ui/command"

const cats = ["Siamese", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Sphynx"]
const dogs = ["German Shepherd", "Bulldog", "Labrador Retriever", "Golden Retriever", "French Bulldog", "Siberian Husky"]

const mockApiSearch = (searchQuery: string): string[] => {
  const lookingForCats = searchQuery.includes("cat")
  const lookingForDogs = searchQuery.includes("dog")
  if (lookingForCats && lookingForDogs) {
    return [...cats, ...dogs]
  } else if (lookingForCats) {
    return cats
  } else if (lookingForDogs) {
    return dogs
  } else {
    return []
  }
}

export default function CommandCustomFiltering() {
  const [commandInput, setCommandInput] = React.useState<string>("")
  const [results, setResults] = React.useState<string[]>([])
  React.useEffect(() => {
    setResults(mockApiSearch(commandInput))
  }, [commandInput])

  return (
    <Command className="rounded-lg border shadow-md" shouldFilter={false}>
      <CommandInput placeholder="Type 'cat' or 'dog'..." value={commandInput} onValueChange={setCommandInput} />
      <CommandList>
        <CommandEmpty>{ commandInput === "" ? "Start typing to load results": "No results found." }</CommandEmpty>
        <CommandGroup>
          {
            results.map((result: string) => <CommandItem key={result} value={result}>
              { result }
            </CommandItem>)
          }
        </CommandGroup>
      </CommandList>
    </Command>
  )
}

miquelvir avatar Oct 21 '23 23:10 miquelvir

I've create a combobox component that has an 'onSearchChange', that we'll use to make our API call given what the user is typing:

Here's an real-world example on how to use it, first I have a state for the cities (shown in the combobox)

    const [cities, setCities] = useState<ComboBoxItemType[]>([])

Here's how to use the component:

      <Combobox
        className='w-full'
        items={cities}
        onSelect={value => handleChange('cityId', Number(value))}
        onSearchChange={handleCitySearchChanged}
        value={editedAddress.cityId ? editedAddress.cityId.toString() : ''}
      />

And our event that will make the API call, in my case I'm using server actions:

  const handleCitySearchChanged = async (value: string) => {
    const response = await list(value)

    if (response.type === 'error') {
      toaster.send(response)
      return
    }

    setCities(
      response.data?.map(city => ({
        value: city.id.toString(),
        label: `${city.stateUf} - ${city.name}`
      })) || []
    )
  }

Alright if that looks like it may work for you, here's the complete combobox.tsx file:

'use client'

import * as React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useDebouncedCallback } from 'use-debounce'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem
} from '@/components/ui/command'
import {
  Popover,
  PopoverContent,
  PopoverTrigger
} from '@/components/ui/popover'
import { ScrollArea } from './scroll-area'

export type ComboBoxItemType = {
  value: string
  label: string
}

type ComboboxProps = {
  value?: string
  onSelect: (value: string | undefined) => void
  items: ComboBoxItemType[]
  searchPlaceholder?: string
  noResultsMsg?: string
  selectItemMsg?: string
  className?: string
  unselect?: boolean
  unselectMsg?: string
  onSearchChange?: (e: string) => void
}

const popOverStyles = {
  width: 'var(--radix-popover-trigger-width)'
}

export function Combobox ({
  value,
  onSelect,
  items,
  searchPlaceholder = 'Pesquisar item...',
  noResultsMsg = 'Nenhum item encontrado',
  selectItemMsg = 'Selecione um item',
  className,
  unselect = false,
  unselectMsg = 'Nenhum',
  onSearchChange
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false)

  const handleOnSearchChange = useDebouncedCallback((e: string) => {
    if (e === '') {
      return
    }

    if (onSearchChange) {
      onSearchChange(e)
    }
  }, 300)

  return (
    <Popover open={open} onOpenChange={setOpen} modal={true}>
      <PopoverTrigger asChild>
        <Button
          variant='outline'
          role='combobox'
          aria-expanded={open}
          className={cn('justify-between', className)}
        >
          {value
            ? items.find(item => item.value === value)?.label
            : selectItemMsg}
          <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        style={popOverStyles}
        className='p-0 popover-content-width-same-as-its-trigger'
      >
        <Command>
          <CommandInput
            placeholder={searchPlaceholder}
            onValueChange={handleOnSearchChange}
          />
          <ScrollArea className='max-h-[220px] overflow-auto'>
            <CommandEmpty>{noResultsMsg}</CommandEmpty>
            <CommandGroup>
              {unselect && (
                <CommandItem
                  key='unselect'
                  value=''
                  onSelect={() => {
                    onSelect('')
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === '' ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {unselectMsg}
                </CommandItem>
              )}
              {items.map(item => (
                <CommandItem
                  key={item.value}
                  value={item.label}
                  onSelect={currentValue => {
                    onSelect(
                      currentValue === item.label.toLowerCase()
                        ? item.value
                        : ''
                    )
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === item.value ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {item.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </ScrollArea>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

mtnoronha avatar Jan 18 '24 11:01 mtnoronha

TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))

silsgah avatar Apr 27 '24 12:04 silsgah

TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))

That's because CommandGroup must be in a CommandList. You also have to change some CSS in the command component to avoid items being greyed out. Check out this answer for that: #2944

electricddev avatar Apr 29 '24 06:04 electricddev

I've create a combobox component that has an 'onSearchChange', that we'll use to make our API call given what the user is typing:

Here's an real-world example on how to use it, first I have a state for the cities (shown in the combobox)

    const [cities, setCities] = useState<ComboBoxItemType[]>([])

Here's how to use the component:

      <Combobox
        className='w-full'
        items={cities}
        onSelect={value => handleChange('cityId', Number(value))}
        onSearchChange={handleCitySearchChanged}
        value={editedAddress.cityId ? editedAddress.cityId.toString() : ''}
      />

And our event that will make the API call, in my case I'm using server actions:

  const handleCitySearchChanged = async (value: string) => {
    const response = await list(value)

    if (response.type === 'error') {
      toaster.send(response)
      return
    }

    setCities(
      response.data?.map(city => ({
        value: city.id.toString(),
        label: `${city.stateUf} - ${city.name}`
      })) || []
    )
  }

Alright if that looks like it may work for you, here's the complete combobox.tsx file:

'use client'

import * as React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useDebouncedCallback } from 'use-debounce'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem
} from '@/components/ui/command'
import {
  Popover,
  PopoverContent,
  PopoverTrigger
} from '@/components/ui/popover'
import { ScrollArea } from './scroll-area'

export type ComboBoxItemType = {
  value: string
  label: string
}

type ComboboxProps = {
  value?: string
  onSelect: (value: string | undefined) => void
  items: ComboBoxItemType[]
  searchPlaceholder?: string
  noResultsMsg?: string
  selectItemMsg?: string
  className?: string
  unselect?: boolean
  unselectMsg?: string
  onSearchChange?: (e: string) => void
}

const popOverStyles = {
  width: 'var(--radix-popover-trigger-width)'
}

export function Combobox ({
  value,
  onSelect,
  items,
  searchPlaceholder = 'Pesquisar item...',
  noResultsMsg = 'Nenhum item encontrado',
  selectItemMsg = 'Selecione um item',
  className,
  unselect = false,
  unselectMsg = 'Nenhum',
  onSearchChange
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false)

  const handleOnSearchChange = useDebouncedCallback((e: string) => {
    if (e === '') {
      return
    }

    if (onSearchChange) {
      onSearchChange(e)
    }
  }, 300)

  return (
    <Popover open={open} onOpenChange={setOpen} modal={true}>
      <PopoverTrigger asChild>
        <Button
          variant='outline'
          role='combobox'
          aria-expanded={open}
          className={cn('justify-between', className)}
        >
          {value
            ? items.find(item => item.value === value)?.label
            : selectItemMsg}
          <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        style={popOverStyles}
        className='p-0 popover-content-width-same-as-its-trigger'
      >
        <Command>
          <CommandInput
            placeholder={searchPlaceholder}
            onValueChange={handleOnSearchChange}
          />
          <ScrollArea className='max-h-[220px] overflow-auto'>
            <CommandEmpty>{noResultsMsg}</CommandEmpty>
            <CommandGroup>
              {unselect && (
                <CommandItem
                  key='unselect'
                  value=''
                  onSelect={() => {
                    onSelect('')
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === '' ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {unselectMsg}
                </CommandItem>
              )}
              {items.map(item => (
                <CommandItem
                  key={item.value}
                  value={item.label}
                  onSelect={currentValue => {
                    onSelect(
                      currentValue === item.label.toLowerCase()
                        ? item.value
                        : ''
                    )
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === item.value ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {item.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </ScrollArea>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

Hi @mtnoronha Can you please show what you have in handleChange callback and also the editedAddress object?

dodi-br avatar May 12 '24 16:05 dodi-br

I've create a combobox component that has an 'onSearchChange', that we'll use to make our API call given what the user is typing:

Here's an real-world example on how to use it, first I have a state for the cities (shown in the combobox)

    const [cities, setCities] = useState<ComboBoxItemType[]>([])

Here's how to use the component:

      <Combobox
        className='w-full'
        items={cities}
        onSelect={value => handleChange('cityId', Number(value))}
        onSearchChange={handleCitySearchChanged}
        value={editedAddress.cityId ? editedAddress.cityId.toString() : ''}
      />

And our event that will make the API call, in my case I'm using server actions:

  const handleCitySearchChanged = async (value: string) => {
    const response = await list(value)

    if (response.type === 'error') {
      toaster.send(response)
      return
    }

    setCities(
      response.data?.map(city => ({
        value: city.id.toString(),
        label: `${city.stateUf} - ${city.name}`
      })) || []
    )
  }

Alright if that looks like it may work for you, here's the complete combobox.tsx file:

'use client'

import * as React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useDebouncedCallback } from 'use-debounce'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem
} from '@/components/ui/command'
import {
  Popover,
  PopoverContent,
  PopoverTrigger
} from '@/components/ui/popover'
import { ScrollArea } from './scroll-area'

export type ComboBoxItemType = {
  value: string
  label: string
}

type ComboboxProps = {
  value?: string
  onSelect: (value: string | undefined) => void
  items: ComboBoxItemType[]
  searchPlaceholder?: string
  noResultsMsg?: string
  selectItemMsg?: string
  className?: string
  unselect?: boolean
  unselectMsg?: string
  onSearchChange?: (e: string) => void
}

const popOverStyles = {
  width: 'var(--radix-popover-trigger-width)'
}

export function Combobox ({
  value,
  onSelect,
  items,
  searchPlaceholder = 'Pesquisar item...',
  noResultsMsg = 'Nenhum item encontrado',
  selectItemMsg = 'Selecione um item',
  className,
  unselect = false,
  unselectMsg = 'Nenhum',
  onSearchChange
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false)

  const handleOnSearchChange = useDebouncedCallback((e: string) => {
    if (e === '') {
      return
    }

    if (onSearchChange) {
      onSearchChange(e)
    }
  }, 300)

  return (
    <Popover open={open} onOpenChange={setOpen} modal={true}>
      <PopoverTrigger asChild>
        <Button
          variant='outline'
          type='button'
          role='combobox'
          aria-expanded={open}
          className={cn('justify-between', className)}
        >
          {value
            ? items.find(item => item.value === value)?.label
            : selectItemMsg}
          <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        style={popOverStyles}
        className='p-0 popover-content-width-same-as-its-trigger'
      >
        <Command>
          <CommandInput
            placeholder={searchPlaceholder}
            onValueChange={handleOnSearchChange}
          />
          <ScrollArea className='max-h-[220px] overflow-auto'>
            <CommandEmpty>{noResultsMsg}</CommandEmpty>
            <CommandGroup>
              {unselect && (
                <CommandItem
                  key='unselect'
                  value=''
                  onSelect={() => {
                    onSelect('')
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === '' ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {unselectMsg}
                </CommandItem>
              )}
              {items.map(item => (
                <CommandItem
                  key={item.value}
                  value={item.label}
                  onSelect={currentValue => {
                    onSelect(
                      currentValue === item.label.toLowerCase()
                        ? item.value
                        : ''
                    )
                    setOpen(false)
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === item.value ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  {item.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </ScrollArea>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

I included type='button' to avoid submitting

rogeriolimabr avatar May 17 '24 01:05 rogeriolimabr

a bit late but say you use react query, how would you add infinite scroll to the combobox? ie how would you implement the intersection observer

osman-sultan avatar May 27 '24 03:05 osman-sultan

@osman-sultan just a guess, but wouldn't be possible to use onScroll for the CommandList and check when reaching the end of the list? And when that happens, you could trigger a fetchNextPage assuming you are using useInfiniteQuery from tanstack. If not take a look here: https://tanstack.com/query/v4/docs/framework/react/guides/infinite-queries

I believe with that approach you can create a infinite scroll behavior.

matusca96 avatar Jun 02 '24 21:06 matusca96

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Jun 25 '24 23:06 shadcn

The input is changing and users are being fetched correctly,BUT the comboItems are not updating and facing unexpected behaviour. Is there any other component with same UI? Or is there any fix for this. I tried changing so many times but still not working?

import {
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "./ui/dialog";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "./ui/command";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import { User } from "@/interfaces/entities";
import { useEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { getUsers } from "@/services/api";
import { ApiResponse } from "@/interfaces/apiResponse";
import { useAuth } from "@/hooks/useAuth";
import { AuthContextProps } from "@/interfaces/authContextProps";
interface NewChatDialogProps {
  target: "newChat" | "newGroup";
  open: boolean;
  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export function NewChatDialog({
  target,
  open,
  setOpen,
}: NewChatDialogProps): React.ReactNode {
  const [selectedUsers, setSelectedUsers] = useState<Array<User>>([]);
  const [users, setUsers] = useState<Array<User>>([]);
  const [input, setInput] = useState("");
  const { user }: AuthContextProps = useAuth();
  const [fetching, setFetching] = useState(false);
  const debounceTimer = useRef<NodeJS.Timeout | null>(null);
  const inputLength = input.trim().length;

  useEffect(() => {
    handleInputChange();
  }, [input]);

  useEffect(() => {
    handleDialogOpenChange();
  }, [open]);

  const handleInputChange = () => {
    console.log(input);
    function debounceFetchUsers() {
      if (debounceTimer.current) {
        clearTimeout(debounceTimer.current);
      }
      debounceTimer.current = setTimeout(() => {
        if (input === "") {
          setUsers([]);
          return;
        }
        // setInput(input);
        async function fetchUsers() {
          try {
            setFetching(true);
            const res: { data: ApiResponse } = await getUsers(input);
            console.log(res.data);
            res.data.users = res.data.users?.filter(
              (person) => person.user_id !== user?.user_id
            );
            setUsers(res.data.users as Array<User>);
          } catch (error) {
            console.error(error);
          }
        }
        fetchUsers();
      }, 500); // Adjust the delay as needed
    }
    debounceFetchUsers();
    // console.log(selectedPeople);
    return () => {
      if (debounceTimer.current) {
        clearTimeout(debounceTimer.current);
      }
    };
  };

  const handleDialogOpenChange = () => {
    setInput("");
    setSelectedUsers([]);
    setUsers([]);
  };

  return (
    <DialogContent className="gap-0 p-0 outline-none">
      <DialogHeader className="px-4 pb-4 pt-5">
        <DialogTitle>New message</DialogTitle>
        <DialogDescription>
          Invite a user to this thread. This will create a new group message.
        </DialogDescription>
      </DialogHeader>
      <Command className="overflow-hidden rounded-t-none border-t">
        <CommandInput
          value={input}
          onValueChange={setInput}
          placeholder="Search user..."
        />
        <CommandList>
          <CommandEmpty>No users found.</CommandEmpty>
          <CommandGroup className="p-2">
            {users.map((user) => (
              <CommandItem
                key={user.email}
                className="flex items-center px-2"
                onSelect={() => {
                  if (selectedUsers.includes(user)) {
                    return setSelectedUsers(
                      selectedUsers.filter(
                        (selectedUser) => selectedUser !== user
                      )
                    );
                  }

                  return setSelectedUsers(
                    [...users].filter((u) =>
                      [...selectedUsers, user].includes(u)
                    )
                  );
                }}
              >
                <Avatar>
                  <AvatarImage src={user.picture} alt="Image" />
                  <AvatarFallback>{user.username[0]}</AvatarFallback>
                </Avatar>
                <div className="ml-2">
                  <p className="text-sm font-medium leading-none">
                    {user.username}
                  </p>
                  <p className="text-sm text-muted-foreground">{user.email}</p>
                </div>
                {selectedUsers.includes(user) ? (
                  <Check className="ml-auto flex h-5 w-5 text-primary" />
                ) : null}
              </CommandItem>
            ))}
          </CommandGroup>
        </CommandList>
      </Command>

      <DialogFooter className="flex items-center border-t p-4 sm:justify-between">
        {selectedUsers.length > 0 ? (
          <div className="flex -space-x-2 overflow-hidden">
            {selectedUsers.map((user) => (
              <Avatar
                key={user.email}
                className="inline-block border-2 border-background"
              >
                <AvatarImage src={user.picture} />
                <AvatarFallback>{user.username[0]}</AvatarFallback>
              </Avatar>
            ))}
          </div>
        ) : (
          <p className="text-sm text-muted-foreground">
            Select users to add to this thread.
          </p>
        )}
        <Button
          disabled={selectedUsers.length < 2}
          onClick={() => {
            setOpen(false);
          }}
        >
          Continue
        </Button>
      </DialogFooter>
    </DialogContent>
  );
}

M0hitReddy avatar Aug 08 '24 04:08 M0hitReddy