ui
ui copied to clipboard
Combobox with remote search example
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.
+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.
+1 Need this too!
+1 Working on this but new data list doesn't display
+1 🙏🏻
@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>
)
}
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>
)
}
TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
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
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?
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
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 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.
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.
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>
);
}