ui
ui copied to clipboard
feat: Combobox (Autocomplete) component
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
Does the Combobox not provide the necessary behaviour?
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?
Combobox
https://user-images.githubusercontent.com/79363260/235977754-f10c4034-7d74-48b0-832e-f545a9655195.mp4
Implementation
interface 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>
);
};
Stories
interface 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>
);
};
DatePicker
I tried to do something similar to the Vercel date picker (https://vercel.com/dashboard/usage)
https://user-images.githubusercontent.com/79363260/235977865-ebd82491-d0b8-416b-9549-7f7ea0f26645.mp4
Implementation
interface 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>
);
};
Stories
const 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",
},
]}
/>
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.
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 zod
and Form
(see here). It doesn't use the updated Component. But hopefully you can work with it.
Really appreciate it @mxkaske! It works very well with me. I also change the behavior of the DataItem
a little bit to this :
type DataItem = { value: string; label: React.ReactNode; badge: React.ReactNode; };
in order to make the UI more customizable.
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.
I would recommend adding a z-index when the CommandItem
is opened, it is pushed underneath when having multiple multi select etc
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 And the source code here: https://github.com/armandsalle/my-site/blob/main/src/components/autocomplete.tsx
https://github.com/shadcn-ui/ui/assets/28579123/bee6d0a8-0737-4b69-a9b9-a9b31bc3bc5f
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 And the source code here: https://github.com/armandsalle/my-site/blob/main/src/components/autocomplete.tsx
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 clearIfOptionSelected?: boolean
that allow me to clear the input if an option is selected.
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.
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?
Combobox
Recording.2023-05-03.181332.mp4
Implementation
interface 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> ); };
Stories
interface 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> ); };
DatePicker
I tried to do something similar to the Vercel date picker (https://vercel.com/dashboard/usage)
Recording.2023-05-03.181734.mp4
Implementation
interface 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> ); };
Stories
const 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' };
@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 createSafeContext
, useControllableState
, etc. come from or what else need to be installed to get this code to work.
It would be great if the combobox also accepts custom value like a combobox in react-aria.
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: There are three components:
-
v-select
: a plain dropdown list -
v-autocomplete
: it is av-select
+ ability to search between items -
v-combobox
: it is av-autocomplete
+ ability to accept custom user text without limiting it to only choosing one item from the list
Believe it or not, all of them are useful in different circumstances.
So, I would be really happy to see the combobox
component getting this feature!
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?
Combobox
Recording.2023-05-03.181332.mp4
Implementation
interface 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> ); };
Stories
interface 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> ); };
DatePicker
I tried to do something similar to the Vercel date picker (vercel.com/dashboard/usage) Recording.2023-05-03.181734.mp4
Implementation
interface 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> ); };
Stories
const 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' };
@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
createSafeContext
,useControllableState
, etc. come from or what else need to be installed to get this code to work.
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: https://mantine.dev/core/autocomplete https://mantine.dev/core/combobox
+1 for autocomplete on <Input />
components
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 cmdk
's breaking changes on Shadcn's Combobox. See relevant issue https://github.com/shadcn-ui/ui/pull/2945.
This example is composed with <Command>
, <Popover>
and <Input>
.
The benefit of using <Popover>
component as opposed to using CSS absolute positioning is that the Combobox options are automatically kept in view as floating-ui
handled it internally.
This only works with cmdk ^1.0.0
.
https://github.com/shadcn-ui/ui/assets/40173716/c1134a9c-1a05-4c82-bc59-93632e74d67b
"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]
{!open && <CommandList aria-hidden="true" className="hidden" />}
is needed because<CommandList>
must be inside<Command>
component at all times.
The current behaviour of the component is partially based on MUI combobox example.
- If
value
is selected,onBlur
will setsearch
to the selected framework's label. - If no
value
is selected,onBlur
will setsearch
to empty string. - Focusing on the Input opens up the popup.
- When
search
is empty, clicking on the input will toggle the popup. - Pressing
ESC
key will close the popup, while remain focus in the input.
You can make adjustments accordingly to match the component desired behaviour.
E.g,
- Removing
onFocus={() => setOpen(true)}
onCommandPrimitive.Input
can achieve similar behaviour in MUI ofopenOnFocus={false}
. - Removing
onMouseDown={(e) => e.preventDefault()}
onCommandItem
can achieve similar behaviour in MUIblurOnSelect
. - Customise the behaviour of
onBlur
ofCommandPrimitive.Input
'ssetSearch
by not setting it back to empty string so thatsearch
string can be resumed.
The sky's the limit.
Thank you @junwen-k! Your approach helped me getting it to work with a (resizeable) Textarea without modifing cmdk
.
Your note about the CommandList
was very helpful, although in my version I needed to get rid of the !open
condition.
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 };
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
How can we achieve to use only one choice, not multiple? including we don't need badge
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
cmdk
's breaking changes on Shadcn's Combobox. See relevant issue #2945.This example is composed with
<Command>
,<Popover>
and<Input>
. The benefit of using<Popover>
component as opposed to using CSS absolute positioning is that the Combobox options are automatically kept in view asfloating-ui
handled it internally.This only works with
cmdk ^1.0.0
.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
{!open && <CommandList aria-hidden="true" className="hidden" />}
is needed because<CommandList>
must be inside<Command>
component at all times.The current behaviour of the component is partially based on MUI combobox example.
- If
value
is selected,onBlur
will setsearch
to the selected framework's label.- If no
value
is selected,onBlur
will setsearch
to empty string.- Focusing on the Input opens up the popup.
- When
search
is empty, clicking on the input will toggle the popup.- Pressing
ESC
key will close the popup, while remain focus in the input.You can make adjustments accordingly to match the component desired behaviour.
E.g,
- Removing
onFocus={() => setOpen(true)}
onCommandPrimitive.Input
can achieve similar behaviour in MUI ofopenOnFocus={false}
.- Removing
onMouseDown={(e) => e.preventDefault()}
onCommandItem
can achieve similar behaviour in MUIblurOnSelect
.- Customise the behaviour of
onBlur
ofCommandPrimitive.Input
'ssetSearch
by not setting it back to empty string so thatsearch
string can be resumed.The sky's the limit.
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.