-
Notifications
You must be signed in to change notification settings - Fork 4.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Combobox (Autocomplete) component #173
Comments
Does the Combobox not provide the necessary behaviour? |
Actually, yes, I somehow did not notice it. But it would be more convenient if it was as a separate component that can be easily reused and the multiselect functionality would also be useful. |
I created it, but I think it can be improved/simplified/refactored, I hope this code will be useful for creating these reused components. @shadcn what's your take on that? ComboboxRecording.2023-05-03.181332.mp4Implementationinterface ComboboxContextValue {
isSelected: (value: unknown) => boolean;
onSelect: (value: unknown) => void;
}
export const [ComboboxProvider, useComboboxContext] =
createSafeContext<ComboboxContextValue>({
name: 'ComboboxContext',
});
interface ComboboxCommonProps<TValue> {
children: React.ReactNode;
displayValue?: (item: TValue) => string;
placeholder?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
inputPlaceholder?: string;
search?: string;
onSearchChange?: (search: string) => void;
emptyState?: React.ReactNode;
}
type ComboboxFilterProps =
| {
shouldFilter?: true;
filterFn?: React.ComponentProps<typeof Command>['filter'];
}
| {
shouldFilter: false;
filterFn?: never;
};
type ComboboxValueProps<TValue> =
| {
multiple?: false;
value?: TValue | null;
defaultValue?: TValue | null;
onValueChange?(value: TValue | null): void;
}
| {
multiple: true;
value?: TValue[] | null;
defaultValue?: TValue[] | null;
onValueChange?(value: TValue[] | null): void;
};
export type ComboboxProps<TValue> = ComboboxCommonProps<TValue> &
ComboboxValueProps<TValue> &
ComboboxFilterProps;
export const Combobox = <TValue,>({
children,
displayValue,
placeholder = 'Select an option',
value: valueProp,
defaultValue,
onValueChange,
multiple = false,
shouldFilter = true,
filterFn,
open: openProp,
defaultOpen,
onOpenChange,
inputPlaceholder = 'Search...',
search,
onSearchChange,
emptyState = 'Nothing found.',
}: ComboboxProps<TValue>) => {
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: (state) => {
onValueChange?.(state as unknown as TValue & TValue[]);
},
});
const isSelected = (selectedValue: unknown) => {
if (Array.isArray(value)) {
return value.includes(selectedValue as TValue);
}
return value === selectedValue;
};
const handleSelect = (selectedValue: unknown) => {
let newValue: TValue | TValue[] | null = selectedValue as TValue;
if (multiple) {
if (Array.isArray(value)) {
if (value.includes(newValue)) {
const newArr = value.filter((val) => val !== selectedValue);
newValue = newArr.length ? newArr : null;
} else {
newValue = [...value, newValue];
}
} else {
newValue = [newValue];
}
} else if (value === selectedValue) {
newValue = null;
}
setValue(newValue);
setOpen(false);
};
const renderValue = (): string => {
if (value) {
if (Array.isArray(value)) {
return `${value.length} selected`;
}
if (displayValue !== undefined) {
return displayValue(value as unknown as TValue);
}
return placeholder;
}
return placeholder;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button
className="w-full justify-between text-left font-normal"
variant="outline"
rightIcon={
<CaretUpDown className="-mr-1.5 h-5 w-5 text-tertiary-400" />
}
role="combobox"
aria-expanded={open}
>
{renderValue()}
</Button>
</Popover.Trigger>
<Popover.Content
className="w-full min-w-[var(--radix-popover-trigger-width)]"
align="start"
>
<Command filter={filterFn} shouldFilter={shouldFilter}>
<Command.Input
placeholder={inputPlaceholder}
autoFocus
value={search}
onValueChange={onSearchChange}
/>
<Command.List className="max-h-60">
<Command.Empty>{emptyState}</Command.Empty>
<ComboboxProvider value={{ isSelected, onSelect: handleSelect }}>
{children}
</ComboboxProvider>
</Command.List>
</Command>
</Popover.Content>
</Popover>
);
};
interface ComboboxItemOptions<TValue> {
value: TValue;
}
export interface ComboboxItemProps<TValue>
extends ComboboxItemOptions<TValue>,
Omit<
React.ComponentProps<typeof Command.Item>,
keyof ComboboxItemOptions<TValue> | 'onSelect' | 'role'
> {
onSelect?(value: TValue): void;
}
export const ComboboxItem = <
TValue = Parameters<typeof Combobox>[0]['value'],
>({
children,
className,
value,
onSelect,
}: ComboboxItemProps<TValue>) => {
const context = useComboboxContext();
return (
<Command.Item
className={cn('pl-8', className)}
role="option"
onSelect={() => {
context.onSelect(value);
onSelect?.(value);
}}
>
{context.isSelected(value) && (
<Check className="absolute left-2 h-4 w-4" />
)}
{children}
</Command.Item>
);
}; Storiesinterface Framework {
value: string;
label: string;
}
const frameworks = [
{
value: 'next.js',
label: 'Next.js',
},
{
value: 'sveltekit',
label: 'SvelteKit',
},
{
value: 'nuxt.js',
label: 'Nuxt.js',
},
{
value: 'remix',
label: 'Remix',
},
{
value: 'astro',
label: 'Astro',
},
] satisfies Framework[];
interface Person {
id: number;
name: string;
}
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox' },
{ id: 6, name: 'Hellen Schmidt' },
{ id: 7, name: 'Caroline Schultz' },
{ id: 8, name: 'Mason Heaney' },
] satisfies Person[];
export const Basic = () => (
<Combobox
placeholder="Select favorite framework"
displayValue={(framework: Framework) => framework.label}
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
);
export const Multiple = () => (
<Combobox
placeholder="Select favorite frameworks"
displayValue={(framework: Framework) => framework.label}
multiple
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
);
export const WithCustomFilterFn = () => (
<Combobox
placeholder="Select favorite frameworks"
displayValue={(framework: Framework) => framework.label}
filterFn={(value, search) => (value.charAt(0) === search.charAt(0) ? 1 : 0)}
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
);
export const WithControlledFiltering = () => {
const [search, setSearch] = useState('');
const filteredPeople =
search === ''
? people
: people.filter(
(person) =>
person.id
.toString()
.includes(search.toLowerCase().replace(/\s+/g, '')) ||
person.name
.toLowerCase()
.replace(/\s+/g, '')
.includes(search.toLowerCase().replace(/\s+/g, ''))
);
return (
<Combobox
placeholder="Select a person"
displayValue={(person: Person) => person.name}
shouldFilter={false}
search={search}
onSearchChange={(newSearch) => setSearch(newSearch)}
>
{filteredPeople.map((person) => (
<Combobox.Item key={person.id} value={person}>
{person.name}
</Combobox.Item>
))}
</Combobox>
);
};
export const WithControlledOpenState = () => (
<Combobox
placeholder="Select favorite framework"
displayValue={(framework: Framework) => framework.label}
open
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
);
export const WithDefaultValue = () => (
<Combobox
placeholder="Select favorite framework"
displayValue={(framework: Framework) => framework.label}
defaultValue={frameworks[0]}
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
);
export const WithControlledValue = () => {
const [value, setValue] = useState<Framework | null>(frameworks[0] ?? null);
return (
<>
<Combobox
value={value}
onValueChange={setValue}
placeholder="Select favorite framework"
displayValue={(framework: Framework) => framework.label}
>
{frameworks.map((framework) => (
<Combobox.Item key={framework.value} value={framework}>
{framework.label}
</Combobox.Item>
))}
</Combobox>
<pre>{JSON.stringify(value, null, 2)}</pre>
</>
);
};
export const WithinForm = () => {
const [search, setSearch] = useState('');
const filteredPeople =
search === ''
? people
: people.filter(
(person) =>
person.id
.toString()
.includes(search.toLowerCase().replace(/\s+/g, '')) ||
person.name
.toLowerCase()
.replace(/\s+/g, '')
.includes(search.toLowerCase().replace(/\s+/g, ''))
);
return (
<FormControl>
<FormLabel>Share with</FormLabel>
<Combobox
placeholder="Select a person"
displayValue={(person: Person) => person.name}
shouldFilter={false}
search={search}
onSearchChange={(val) => setSearch(val)}
multiple
>
{filteredPeople.map((person) => (
<Combobox.Item key={person.id} value={person}>
{person.name}
</Combobox.Item>
))}
</Combobox>
<FormHelperText>You can search by name or id</FormHelperText>
</FormControl>
);
}; DatePickerI tried to do something similar to the Vercel date picker (https://vercel.com/dashboard/usage) Recording.2023-05-03.181734.mp4Implementationinterface DateTimeInputProps {
type: 'date' | 'time';
date: Date | undefined;
onDateChange: (date: Date) => void;
}
const DateTimeInput = ({ type, date, onDateChange }: DateTimeInputProps) => {
const [value, setValue] = useState<string>('');
const [isValid, setIsValid] = useState(true);
useEffect(() => {
if (!date) {
setValue('');
return;
}
setValue(
type === 'date' ? format(date, 'LLL d, y') : format(date, 'p OOO')
);
setIsValid(true);
}, [date, type]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value: newValue } = e.target;
if (!newValue) {
if (!date) {
setValue('');
} else {
setValue(
type === 'date' ? format(date, 'LLL d, y') : format(date, 'p OOO')
);
}
return;
}
setValue(newValue);
};
const handleInputBlur = () => {
if (!value) {
return;
}
const parsedDate = new Date(
type === 'date'
? value
: `${format(date || new Date(), 'LLL d, y')} ${value}`
);
if (Number.isNaN(parsedDate.getTime())) {
setIsValid(false);
return;
}
setIsValid(true);
onDateChange(parsedDate);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
};
return (
<Input
size="sm"
isInvalid={!isValid}
placeholder={type === 'date' ? 'Date' : 'Time'}
value={value}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
/>
);
};
interface DatePickerCommonProps {
type?: 'date' | 'datetime';
withPresets?: boolean;
}
type DatePickerDateProps =
| {
date?: Date;
onDateChange?: (date: Date) => void;
mode?: 'single';
defaultDate?: Date;
}
| {
date?: DateRange;
onDateChange?: (date: DateRange) => void;
mode?: 'range';
defaultDate?: DateRange;
};
export type DatePickerProps = DatePickerCommonProps & DatePickerDateProps;
const isSingleDate = (
_date: Date | DateRange | undefined,
mode: 'single' | 'range'
): _date is Date | undefined => mode === 'single';
export const DatePicker = ({
date: dateProp,
defaultDate,
onDateChange,
mode = 'single',
type = 'date',
withPresets = false,
}: DatePickerProps) => {
const [selectedDate, setSelectedDate] = useControllableState({
prop: dateProp,
defaultProp: defaultDate,
onChange: (state) => {
onDateChange?.(state as unknown as Date & DateRange);
},
});
// Preserve time of the selected date
const preserveSelectedTime = (date: Date | DateRange | undefined) => {
if (!date) {
return undefined;
}
if (!selectedDate) {
return date;
}
if (isSingleDate(selectedDate, mode)) {
if (selectedDate) {
(date as Date).setMinutes(selectedDate.getMinutes());
(date as Date).setHours(selectedDate.getHours());
}
return date;
}
if (selectedDate.from) {
(date as DateRange).from?.setMinutes(selectedDate.from.getMinutes());
(date as DateRange).from?.setHours(selectedDate.from.getHours());
}
if (selectedDate.to) {
(date as DateRange).to?.setMinutes(selectedDate.to.getMinutes());
(date as DateRange).to?.setHours(selectedDate.to.getHours());
}
return date;
};
const handlePresetSelect = (value: string) => {
const date = addDays(new Date(), parseInt(value, 10));
if (isSingleDate(selectedDate, mode)) {
setSelectedDate(date);
return;
}
if (selectedDate?.from) {
setSelectedDate({ from: selectedDate.from, to: date });
return;
}
setSelectedDate({ from: date, to: undefined });
};
const renderValue = () => {
if (isSingleDate(selectedDate, mode)) {
if (selectedDate) {
return format(
selectedDate,
type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
);
}
return 'Pick a date';
}
if (selectedDate?.from) {
if (selectedDate.to) {
return `${format(
selectedDate.from,
type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
)} - ${format(
selectedDate.to,
type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
)}`;
}
return format(
selectedDate.from,
type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
);
}
return 'Pick a date range';
};
return (
<Popover>
<Popover.Trigger asChild>
<Button
variant="outline"
className={cn(
'w-[300px] justify-start text-left font-normal',
!selectedDate && 'text-base-700'
)}
leftIcon={<CalendarIcon className="h-5 w-5" />}
>
{renderValue()}
</Button>
</Popover.Trigger>
<Popover.Content className="flex w-min flex-col space-y-2 p-2">
{withPresets && (
<Select onValueChange={handlePresetSelect}>
<Select.Trigger>
<Select.Value placeholder="Presets" />
</Select.Trigger>
<Select.Content position="popper">
<Select.Item value="0">Today</Select.Item>
<Select.Item value="1">Tomorrow</Select.Item>
<Select.Item value="3">In 3 days</Select.Item>
<Select.Item value="7">In a week</Select.Item>
</Select.Content>
</Select>
)}
{mode === 'single' ? (
<div
className={cn(
type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
)}
>
<DateTimeInput
type="date"
date={selectedDate as Date}
onDateChange={(date) =>
setSelectedDate(preserveSelectedTime(date))
}
/>
{type === 'datetime' && (
<DateTimeInput
type="time"
date={selectedDate as Date}
onDateChange={setSelectedDate}
/>
)}
</div>
) : (
<div
className={cn(
'flex gap-2',
type === 'datetime' ? 'flex-col' : 'items-center'
)}
>
<div className="space-y-2">
<Label>Start</Label>
<div
className={cn(
type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
)}
>
<DateTimeInput
type="date"
date={(selectedDate as DateRange)?.from}
onDateChange={(date) =>
setSelectedDate(
preserveSelectedTime({
...(selectedDate as DateRange),
from: date,
}) as DateRange
)
}
/>
{type === 'datetime' && (
<DateTimeInput
type="time"
date={(selectedDate as DateRange)?.from}
onDateChange={(date) =>
setSelectedDate({
...(selectedDate as DateRange),
from: date,
})
}
/>
)}
</div>
</div>
<div className="space-y-2">
<Label>End</Label>
<div
className={cn(
type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
)}
>
<DateTimeInput
type="date"
date={(selectedDate as DateRange)?.to}
onDateChange={(date) =>
setSelectedDate(
preserveSelectedTime({
...(selectedDate as DateRange),
to: date,
})
)
}
/>
{type === 'datetime' && (
<DateTimeInput
type="time"
date={(selectedDate as DateRange)?.to}
onDateChange={(date) =>
setSelectedDate({
...(selectedDate as DateRange),
to: date,
})
}
/>
)}
</div>
</div>
</div>
)}
<div className="rounded-lg border">
<Calendar
mode={mode as unknown as 'single' & 'range'}
selected={selectedDate}
onSelect={(date: Date | DateRange | undefined) =>
setSelectedDate(preserveSelectedTime(date))
}
/>
</div>
</Popover.Content>
</Popover>
);
}; Storiesconst Template: Story<DatePickerProps> = (args) => <DatePicker {...args} />;
export const Default = Template.bind({});
Default.args = { ...defaultProps };
export const Range = Template.bind({});
Range.args = { ...defaultProps, mode: 'range' };
export const DateTime = Template.bind({});
DateTime.args = { ...defaultProps, type: 'datetime' };
export const DateTimeRange = Template.bind({});
DateTimeRange.args = { ...defaultProps, mode: 'range', type: 'datetime' }; |
It would be nice to have an enhanced Combobox with multiple support! |
In case anyone else comes to this issue looking for a solution, @mxkaske just dropped a mutli-select component built with cmdk and shadcn components. Demo here: https://craft.mxkaske.dev/post/fancy-multi-select Source here: https://github.com/mxkaske/mxkaske.dev/blob/main/components/craft/fancy-multi-select.tsx |
he're is an updated version that is more dynamic and ready to use: "use client";
import { X } from "lucide-react";
import * as React from "react";
import clsx from "clsx";
import { Command as CommandPrimitive } from "cmdk";
import { Badge } from "components/ui/badge";
import { Command, CommandGroup, CommandItem } from "components/ui/command";
import { Label } from "components/ui/label";
type DataItem = Record<"value" | "label", string>;
export function MultiSelect({
label = "Select an item",
placeholder = "Select an item",
parentClassName,
data,
}: {
label?: string;
placeholder?: string;
parentClassName?: string;
data: DataItem[];
}) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [selected, setSelected] = React.useState<DataItem[]>([]);
const [inputValue, setInputValue] = React.useState("");
const handleUnselect = React.useCallback((item: DataItem) => {
setSelected((prev) => prev.filter((s) => s.value !== item.value));
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "") {
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
return newSelected;
});
}
}
// This is not a default behaviour of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[]
);
const selectables = data.filter((item) => !selected.includes(item));
return (
<div
className={clsx(
label && "gap-1.5",
parentClassName,
"grid w-full items-center"
)}
>
{label && (
<Label className="text-[#344054] text-sm font-medium">{label}</Label>
)}
<Command
onKeyDown={handleKeyDown}
className="overflow-visible bg-transparent"
>
<div className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="flex gap-1 flex-wrap">
{selected.map((item, index) => {
if (index > 1) return;
return (
<Badge key={item.value} variant="secondary">
{item.label}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(item);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(item)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{selected.length > 2 && <p>{`+${selected.length - 2} more`}</p>}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1"
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ? (
<div className="absolute w-full top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandGroup className="h-full overflow-auto">
{selectables.map((framework) => {
return (
<CommandItem
key={framework.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue("");
setSelected((prev) => [...prev, framework]);
}}
>
{framework.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
) : null}
</div>
</Command>
</div>
);
}
You can use it like this: <MultiSelect
label="Salect frameworks"
placeholder="Select more"
data={[
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
{
value: "wordpress",
label: "WordPress",
},
{
value: "express.js",
label: "Express.js",
},
{
value: "nest.js",
label: "Nest.js",
},
]}
/>
|
Thanks @MEddarhri. This looks great, plus customizable. I wonder how can we optimize this with form and zod. I am really a beginner here, and it would be great if you could provide some ideas on how to implement them. |
Hey @Lenghak, I recently published a possible way to use |
Really appreciate it @mxkaske! It works very well with me. I also change the behavior of the |
I would recommend adding a z-index when the |
Hi all 👋 I made an autocomplete field based on @mxkaske component, but mine is not a multi select one. You can find an exemple here: https://www.armand-salle.fr/post/autocomplete-select-shadcn-ui CleanShot.2023-07-30.at.17.01.24.mp4 |
This is amazing, what about clearing? |
@Semkoo For my needs I added an additional prop to the component In the component code I added this // Clear field if `clearIfOptionSelected` is true
// Useful when you want to clear the field after the user selects an option
useEffect(() => {
if (clearIfOptionSelected) {
setSelected({} as Option)
setInputValue("")
}
}, [clearIfOptionSelected]) But if you want to add a clear/reset button inside the dropdown you can easily do that 👍 |
What makes above solution very confusing was how do they work with react-hook-form/zodForm to take inputs, if I am using useFieldArray, and the value can only take string, but the field.value is an array of strings |
I'm also looking for a search/autocomplete component. I tried to build one using radix-ui popover and cmdk but it seems that cmdk is not composable in that way. It breaks down when the list is not rendered as child of the root. The examples above are nice but they only have a very simple absolute positioning of the list. Would be nice to get those working with a radix popover. |
@its-monotype This looks great. I'd love to use it in a project. Is there a license associated with it? Do you have a full working copy that includes imports? I'm not sure where |
It would be great if the combobox also accepts custom value like a combobox in react-aria. |
Yes, I would also need this behavior. The autocomplete would be a helper for the user, so they might find what they would like to write in it, but it should not be limited to accepting suggestions. |
In the Vue world, Vuetify.js handled it very nicely:
Believe it or not, all of them are useful in different circumstances. |
Pretty late reply but i think useControllableState is this internal utility used in radix ui. I still have no idea what createSafeContext would be tho |
Would be nice if the Combobox had ways to have the CommandInput detatched from the popover and instead was the driver for triggering the display of the popover |
A combobox and autocomplete are 2 different types of components. Maybe @shadcn assumes they are the same thing, and thats why autocomplete does not exist in shadcn ui. Using examples from mantine ui: |
+1 for autocomplete on |
Here's an example of Combobox Input which is pretty common. (similar to Material UI) I wanted to contribute this example but is currently blocked by This example is composed with This only works with Screen.Recording.2024-04-14.at.9.50.39.PM.mov"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { Command as CommandPrimitive } from "cmdk"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/registry/new-york/ui/command"
import { Input } from "@/registry/new-york/ui/input"
import { Popover, PopoverContent } from "@/registry/new-york/ui/popover"
const frameworks = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
]
export default function ComboboxInput() {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
const [value, setValue] = React.useState("")
return (
<div className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<Command>
<PopoverPrimitive.Anchor asChild>
<CommandPrimitive.Input
asChild
value={search}
onValueChange={setSearch}
onKeyDown={(e) => setOpen(e.key !== "Escape")}
onMouseDown={() => setOpen((open) => !!search || !open)}
onFocus={() => setOpen(true)}
onBlur={(e) => {
if (!e.relatedTarget?.hasAttribute("cmdk-list")) {
setSearch(
value
? frameworks.find(
(framework) => framework.value === value
)?.label ?? ""
: ""
)
}
}}
>
<Input placeholder="Select framework..." className="w-[200px]" />
</CommandPrimitive.Input>
</PopoverPrimitive.Anchor>
{!open && <CommandList aria-hidden="true" className="hidden" />}
<PopoverContent
asChild
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (
e.target instanceof Element &&
e.target.hasAttribute("cmdk-input")
) {
e.preventDefault()
}
}}
className="w-[--radix-popover-trigger-width] p-0"
>
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{frameworks.map((framework) => (
<CommandItem
key={framework.value}
value={framework.value}
onMouseDown={(e) => e.preventDefault()}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue)
setSearch(
currentValue === value
? ""
: frameworks.find(
(framework) => framework.value === currentValue
)?.label ?? ""
)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === framework.value ? "opacity-100" : "opacity-0"
)}
/>
{framework.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</PopoverContent>
</Command>
</Popover>
</div>
)
} Note
The current behaviour of the component is partially based on MUI combobox example.
You can make adjustments accordingly to match the component desired behaviour. E.g,
The sky's the limit. |
Thank you @junwen-k! Your approach helped me getting it to work with a (resizeable) Textarea without modifing Your note about the |
I am wrapping commandGroup with CommandList and in it also also the CommandEmpty Component and also have changed the classes needed to be updated. But still i am getting the not iteratable error if l have selected all items in the list and do arrow up on down. Than i get this error import React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/cn";
import { Badge } from "@/components/ui/Badge";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
CommandEmpty,
} from "@/components/ui/Command";
import { Label } from "@/components/ui/Label";
import { X as XIcon } from "lucide-react";
type Option = Record<"value" | "label", string>;
const MultiSelect = ({
options,
label,
placeholder = "Select an item",
className,
}: {
options: Option[];
label?: string;
placeholder?: string;
className?: string;
}) => {
const [open, setOpen] = React.useState(false);
const [selected, setSelected] = React.useState<Option[]>([]);
const [inputValue, setInputValue] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const handleUnselect = React.useCallback((option: Option) => {
setSelected((prev) => prev.filter((s) => s.value !== option.value));
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "") {
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
return newSelected;
});
}
}
// This is not a default behaviour of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[],
);
const selectables = options.filter((option) => !selected.includes(option));
return (
<div
className={cn(label && "gap-1.5", className, "grid w-full items-center")}
>
{label && (
<Label className=" text-sm font-medium text-black">{label}</Label>
)}
<Command
onKeyDown={handleKeyDown}
className="overflow-visible bg-transparent"
>
<div className="border-input ring-offset-background focus-within:ring-ring group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2">
<div className="flex flex-wrap gap-1">
{selected.map((option, index) => {
if (index > 1) return;
return (
<Badge key={option.value} variant="secondary">
{option.label}
<button
className="ring-offset-background focus:ring-ring ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<XIcon className="text-muted-foreground hover:text-foreground h-3 w-3" />
</button>
</Badge>
);
})}
{selected.length > 2 && <p>{`+${selected.length - 2} more`}</p>}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="placeholder:text-muted-foreground ml-2 flex-1 bg-transparent outline-none"
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ? (
<div className="bg-popover text-popover-foreground absolute top-0 w-full rounded-md border shadow-md outline-none animate-in">
<CommandList>
<CommandEmpty>No department found</CommandEmpty>
<CommandGroup className="h-full overflow-auto">
{selectables.map((option) => {
return (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue("");
setSelected((prev) => [...prev, option]);
}}
>
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</div>
) : null}
</div>
</Command>
</div>
);
};
export { MultiSelect }; |
How can we achieve to use only one choice, not multiple? including we don't need badge |
Thanks, it works but when I use this inside dialogue, it doesn't work (always close on selecting) |
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. |
I believe a Combobox (Autocomplete) component is essential in any UI kit, as it is commonly used in apps when choosing from related entities in a form or dealing with large data sets. There are several ways to implement it, and I am considering creating it using HeadlessUI and in addition replacing Select from RadixUI with ListBox to have a multi-select functionality, at least until Radix includes it in their library. Alternatively, we could implement it using Downshift, React-Aria, or Radix Popover + cmdk.
I wanted to start implementing it, but I encountered an issue with TypeScript props in HeadlessUI when creating a custom reusable component, similar to what we do with Radix. Unfortunately, I couldn't find any examples of how to create a custom reusable component using HeadlessUI. If anyone has any ideas or suggestions on how to implement it, I would greatly appreciate it!
@shadcn, do you have any plans to add this in the near future? If so, what approach or library would you recommend using?
Note: You can find the issue I encountered with HeadlessUI props here
The text was updated successfully, but these errors were encountered: