ui
ui copied to clipboard
Load Async Response Into Combobox Options
Has anyone successfully built an "Autocomplete" combobox that takes in throttled data from an api endpoint? The options displayed only update when closing and reopening the Combobox but that's not ideal for any user. We have a lot of items and returning all of them at once isn't a viable solution at all.
The idea is to let the user type in their item and a request will be sent to the server every few seconds and return matching records in their options.
https://github.com/shadcn-ui/ui/assets/46979095/73195066-17da-4c00-a853-7406e50de704
// react
import { useEffect, useState } from "react";
// tanstack
import { useQuery } from "@tanstack/react-query";
// acme
import { Combobox, type Option } from "@stew-leonards/core";
// env
import { env } from "~/env";
/** like item */
type LikeItem = {
store_number: number;
item_code: string;
description: string;
plu: string | null;
};
/** get like items api request */
async function getLikeItems(params: {
inputValue: string;
selectedColumn: keyof Omit<LikeItem, "store_number">;
storeNumber: number;
}): Promise<LikeItem[]> {
const response = await fetch(`${env.API_PREFIX}/forms/items/likeitem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
return response.json();
}
/** search item combobox */
export function SearchItemCombobox() {
const {
storeItemsResponse,
setSelectedColumn,
setSelectedValue,
selectedColumn,
setInputValue,
selectedValue,
inputValue,
} = useSearchItemCombobox();
return (
<div>
<Combobox
options={storeItemsResponse.data ?? []}
inputValue={inputValue}
setInputValue={setInputValue}
selectedValue={JSON.stringify(selectedValue)}
setSelectedValue={(value) => {
setSelectedValue(JSON.parse(value));
}}
/>
</div>
);
}
/** search item combobox hook */
export function useSearchItemCombobox() {
const [debouncedValue, setDebouncedValue] = useState("");
const [inputValue, setInputValue] = useState("");
const [selectedValue, setSelectedValue] = useState<LikeItem>();
const [selectedColumn, setSelectedColumn] =
useState<keyof Omit<LikeItem, "store_number">>("description");
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(inputValue);
}, 1500);
return () => {
clearTimeout(handler);
};
}, [inputValue]);
const storeItemsResponse = useQuery({
queryKey: ["storeItems", debouncedValue],
queryFn: () =>
getLikeItems({
inputValue: debouncedValue,
storeNumber: 1,
selectedColumn: selectedColumn,
}),
enabled: debouncedValue !== "",
select: (data) => {
const options: Option<string>[] = data.map((item) => {
let label = `${item.item_code} - ${item.description}`;
if (item.plu) {
label = `${label} (${item.plu})`;
}
return {
label: label,
value: JSON.stringify(item),
};
});
return options;
},
});
return {
storeItemsResponse,
setSelectedColumn,
setSelectedValue,
selectedColumn,
setInputValue,
selectedValue,
inputValue,
};
}
Here's the combobox component I wrote, it's very similar to the one suggested in the docs just with a controlled input
// react
import { useState } from "react";
// acme
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
Popover,
PopoverContent,
PopoverTrigger,
} from "@stew-leonards/core/src";
import { cn } from "@stew-leonards/utils/src";
// lucide
import { Check, ChevronsUpDown } from "lucide-react";
/** option type */
export type Option<T extends NonNullable<unknown>> = {
/** value */
value: T;
/** label */
label: string;
}
/** combo box props */
export type ComboboxProps<T extends NonNullable<unknown>> = {
/** options */
options: Option<T>[];
/** input value */
inputValue: string;
/** set input value */
setInputValue: (value: string) => void;
/** selected value */
selectedValue: T;
/** set selected value */
setSelectedValue: (value: T) => void;
/** placeholder */
placeholder?: string;
}
/** combo box */
export function Combobox<T extends NonNullable<unknown>>({
setSelectedValue,
selectedValue,
setInputValue,
inputValue,
options,
placeholder = "Select an option",
}: ComboboxProps<T>) {
// state
const [open, setOpen] = useState(false);
const compareValues = (value1: T, value2: T) => {
if (typeof value1 === 'object' && typeof value2 === 'object') {
// Compare object values (deep comparison)
return JSON.stringify(value1) === JSON.stringify(value2);
}
// Compare primitive values
return value1 === value2;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{options.find(option => {
return compareValues(option.value, selectedValue);
})?.label || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput
placeholder={placeholder}
value={inputValue}
onInput={(event) => {
setInputValue(event.currentTarget.value);
}}
/>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map(option => (
<CommandItem
key={option.label}
onSelect={() => {
setSelectedValue(option.value);
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedValue === option.value ? "opacity-100" : "opacity-0")}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}
I also have similar problem. Any workaround to fix this bug?
@nestordabon I had to scrap this in favor of React Select due to time constraints (really didn't want to do this).
I plan to keep this issue open, hopefully it gains some traction. I really like the functionality of this "Combobox", async requests like this are very common (especially for us at the item level).
I have a feeling it has something to do with the open state. I'll have to set some personal time outside of work to give this another go.
I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options
Hope it helps!
@melanieseltzer I checked your code and the solution seems to be adding shouldFilter={false} to <Command
Thank you very much, saved so much time.
@melanieseltzer I checked your code and the solution seems to be adding
shouldFilter={false}to<CommandThank you very much, saved so much time.
Finally
I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options
Hope it helps!
Amazing @melanieseltzer !
@shadcn can close this one.
I also needed this. In my case, I'm hitting a search endpoint which itself acts as the filter. Managed to get something working in my app, and created a small public demo for it with some notes: https://github.com/melanieseltzer/example-async-combobox-options
Hope it helps!
Anybody having issues using this code, I found that you need to remove the return null in the search.tsx
// Remove the line below
if (!enabled) return null;
Can we have an official example in the shadcn docs for this?
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.