Skip to content
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

Closed
its-monotype opened this issue Apr 15, 2023 · 30 comments
Closed

feat: Combobox (Autocomplete) component #173

its-monotype opened this issue Apr 15, 2023 · 30 comments
Labels

Comments

@its-monotype
Copy link
Contributor

its-monotype commented Apr 15, 2023

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

@sinclairnick
Copy link

Does the Combobox not provide the necessary behaviour?

@its-monotype
Copy link
Contributor Author

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.

@its-monotype
Copy link
Contributor Author

its-monotype commented May 3, 2023

#283

@its-monotype
Copy link
Contributor Author

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' };

@cirdes
Copy link

cirdes commented May 22, 2023

It would be nice to have an enhanced Combobox with multiple support!

@evangow
Copy link

evangow commented Jun 12, 2023

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

@MEddarhri
Copy link

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",
      },
    ]}
  />
          

@Lenghak
Copy link

Lenghak commented Jul 6, 2023

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.

@mxkaske
Copy link

mxkaske commented Jul 7, 2023

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.

@Lenghak
Copy link

Lenghak commented Jul 8, 2023

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.

@Semkoo
Copy link

Semkoo commented Jul 18, 2023

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

@armandsalle
Copy link

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

@Semkoo
Copy link

Semkoo commented Jul 31, 2023

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?

@armandsalle
Copy link

@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 👍

@zmzlois
Copy link

zmzlois commented Sep 21, 2023

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

@joestrkr
Copy link

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.

@BrendanC23
Copy link

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.

@kennethpole6
Copy link

It would be great if the combobox also accepts custom value like a combobox in react-aria.

@hdadr
Copy link

hdadr commented Jan 6, 2024

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.

@smohammadhn
Copy link

In the Vue world, Vuetify.js handled it very nicely:
There are three components:

  1. v-select: a plain dropdown list
  2. v-autocomplete: it is a v-select + ability to search between items
  3. v-combobox: it is a v-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!

@ramadanomar
Copy link

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

@fafshari
Copy link

fafshari commented Apr 4, 2024

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

@samuelkarani
Copy link

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

@Exitium-DEV
Copy link

+1 for autocomplete on <Input /> components

@junwen-k
Copy link

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 as floating-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.

  1. If value is selected, onBlur will set search to the selected framework's label.
  2. If no value is selected, onBlur will set search to empty string.
  3. Focusing on the Input opens up the popup.
  4. When search is empty, clicking on the input will toggle the popup.
  5. 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,

  1. Removing onFocus={() => setOpen(true)} on CommandPrimitive.Input can achieve similar behaviour in MUI of openOnFocus={false}.
  2. Removing onMouseDown={(e) => e.preventDefault()} on CommandItem can achieve similar behaviour in MUI blurOnSelect.
  3. Customise the behaviour of onBlur of CommandPrimitive.Input's setSearch by not setting it back to empty string so that search string can be resumed.

The sky's the limit.

@masewo
Copy link

masewo commented Apr 30, 2024

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.

@ImamJanjua
Copy link

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 };

@yaba101
Copy link

yaba101 commented Jun 1, 2024

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

@shadcn shadcn added the Stale label Jul 1, 2024
@rahulkumarsingh73690
Copy link

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 as floating-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.

  1. If value is selected, onBlur will set search to the selected framework's label.
  2. If no value is selected, onBlur will set search to empty string.
  3. Focusing on the Input opens up the popup.
  4. When search is empty, clicking on the input will toggle the popup.
  5. 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,

  1. Removing onFocus={() => setOpen(true)} on CommandPrimitive.Input can achieve similar behaviour in MUI of openOnFocus={false}.
  2. Removing onMouseDown={(e) => e.preventDefault()} on CommandItem can achieve similar behaviour in MUI blurOnSelect.
  3. Customise the behaviour of onBlur of CommandPrimitive.Input's setSearch by not setting it back to empty string so that search 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)

@shadcn shadcn removed the Stale label Jul 4, 2024
@shadcn shadcn added the Stale label Jul 20, 2024
@shadcn
Copy link
Collaborator

shadcn commented Jul 28, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests