ui icon indicating copy to clipboard operation
ui copied to clipboard

feat: Combobox (Autocomplete) component

Open its-monotype opened this issue 1 year ago • 29 comments

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

its-monotype avatar Apr 15 '23 18:04 its-monotype

Does the Combobox not provide the necessary behaviour?

sinclairnick avatar Apr 16 '23 01:04 sinclairnick

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 avatar Apr 16 '23 09:04 its-monotype

#283

its-monotype avatar May 03 '23 16:05 its-monotype

I created it, but I think it can be improved/simplified/refactored, I hope this code will be useful for creating these reused components. @shadcn what's your take on that?

Combobox

https://user-images.githubusercontent.com/79363260/235977754-f10c4034-7d74-48b0-832e-f545a9655195.mp4

Implementation

interface ComboboxContextValue {
  isSelected: (value: unknown) => boolean;
  onSelect: (value: unknown) => void;
}

export const [ComboboxProvider, useComboboxContext] =
  createSafeContext<ComboboxContextValue>({
    name: 'ComboboxContext',
  });

interface ComboboxCommonProps<TValue> {
  children: React.ReactNode;
  displayValue?: (item: TValue) => string;
  placeholder?: string;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  inputPlaceholder?: string;
  search?: string;
  onSearchChange?: (search: string) => void;
  emptyState?: React.ReactNode;
}

type ComboboxFilterProps =
  | {
      shouldFilter?: true;
      filterFn?: React.ComponentProps<typeof Command>['filter'];
    }
  | {
      shouldFilter: false;
      filterFn?: never;
    };

type ComboboxValueProps<TValue> =
  | {
      multiple?: false;
      value?: TValue | null;
      defaultValue?: TValue | null;
      onValueChange?(value: TValue | null): void;
    }
  | {
      multiple: true;
      value?: TValue[] | null;
      defaultValue?: TValue[] | null;
      onValueChange?(value: TValue[] | null): void;
    };

export type ComboboxProps<TValue> = ComboboxCommonProps<TValue> &
  ComboboxValueProps<TValue> &
  ComboboxFilterProps;

export const Combobox = <TValue,>({
  children,
  displayValue,
  placeholder = 'Select an option',
  value: valueProp,
  defaultValue,
  onValueChange,
  multiple = false,
  shouldFilter = true,
  filterFn,
  open: openProp,
  defaultOpen,
  onOpenChange,
  inputPlaceholder = 'Search...',
  search,
  onSearchChange,
  emptyState = 'Nothing found.',
}: ComboboxProps<TValue>) => {
  const [open = false, setOpen] = useControllableState({
    prop: openProp,
    defaultProp: defaultOpen,
    onChange: onOpenChange,
  });
  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: (state) => {
      onValueChange?.(state as unknown as TValue & TValue[]);
    },
  });

  const isSelected = (selectedValue: unknown) => {
    if (Array.isArray(value)) {
      return value.includes(selectedValue as TValue);
    }
    return value === selectedValue;
  };

  const handleSelect = (selectedValue: unknown) => {
    let newValue: TValue | TValue[] | null = selectedValue as TValue;

    if (multiple) {
      if (Array.isArray(value)) {
        if (value.includes(newValue)) {
          const newArr = value.filter((val) => val !== selectedValue);
          newValue = newArr.length ? newArr : null;
        } else {
          newValue = [...value, newValue];
        }
      } else {
        newValue = [newValue];
      }
    } else if (value === selectedValue) {
      newValue = null;
    }

    setValue(newValue);
    setOpen(false);
  };

  const renderValue = (): string => {
    if (value) {
      if (Array.isArray(value)) {
        return `${value.length} selected`;
      }
      if (displayValue !== undefined) {
        return displayValue(value as unknown as TValue);
      }
      return placeholder;
    }
    return placeholder;
  };

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <Popover.Trigger asChild>
        <Button
          className="w-full justify-between text-left font-normal"
          variant="outline"
          rightIcon={
            <CaretUpDown className="-mr-1.5 h-5 w-5 text-tertiary-400" />
          }
          role="combobox"
          aria-expanded={open}
        >
          {renderValue()}
        </Button>
      </Popover.Trigger>
      <Popover.Content
        className="w-full min-w-[var(--radix-popover-trigger-width)]"
        align="start"
      >
        <Command filter={filterFn} shouldFilter={shouldFilter}>
          <Command.Input
            placeholder={inputPlaceholder}
            autoFocus
            value={search}
            onValueChange={onSearchChange}
          />
          <Command.List className="max-h-60">
            <Command.Empty>{emptyState}</Command.Empty>
            <ComboboxProvider value={{ isSelected, onSelect: handleSelect }}>
              {children}
            </ComboboxProvider>
          </Command.List>
        </Command>
      </Popover.Content>
    </Popover>
  );
};

interface ComboboxItemOptions<TValue> {
  value: TValue;
}

export interface ComboboxItemProps<TValue>
  extends ComboboxItemOptions<TValue>,
    Omit<
      React.ComponentProps<typeof Command.Item>,
      keyof ComboboxItemOptions<TValue> | 'onSelect' | 'role'
    > {
  onSelect?(value: TValue): void;
}

export const ComboboxItem = <
  TValue = Parameters<typeof Combobox>[0]['value'],
>({
  children,
  className,
  value,
  onSelect,
}: ComboboxItemProps<TValue>) => {
  const context = useComboboxContext();

  return (
    <Command.Item
      className={cn('pl-8', className)}
      role="option"
      onSelect={() => {
        context.onSelect(value);
        onSelect?.(value);
      }}
    >
      {context.isSelected(value) && (
        <Check className="absolute left-2 h-4 w-4" />
      )}
      {children}
    </Command.Item>
  );
};

Stories

interface Framework {
  value: string;
  label: string;
}

const frameworks = [
  {
    value: 'next.js',
    label: 'Next.js',
  },
  {
    value: 'sveltekit',
    label: 'SvelteKit',
  },
  {
    value: 'nuxt.js',
    label: 'Nuxt.js',
  },
  {
    value: 'remix',
    label: 'Remix',
  },
  {
    value: 'astro',
    label: 'Astro',
  },
] satisfies Framework[];

interface Person {
  id: number;
  name: string;
}

const people = [
  { id: 1, name: 'Wade Cooper' },
  { id: 2, name: 'Arlene Mccoy' },
  { id: 3, name: 'Devon Webb' },
  { id: 4, name: 'Tom Cook' },
  { id: 5, name: 'Tanya Fox' },
  { id: 6, name: 'Hellen Schmidt' },
  { id: 7, name: 'Caroline Schultz' },
  { id: 8, name: 'Mason Heaney' },
] satisfies Person[];

export const Basic = () => (
  <Combobox
    placeholder="Select favorite framework"
    displayValue={(framework: Framework) => framework.label}
  >
    {frameworks.map((framework) => (
      <Combobox.Item key={framework.value} value={framework}>
        {framework.label}
      </Combobox.Item>
    ))}
  </Combobox>
);

export const Multiple = () => (
  <Combobox
    placeholder="Select favorite frameworks"
    displayValue={(framework: Framework) => framework.label}
    multiple
  >
    {frameworks.map((framework) => (
      <Combobox.Item key={framework.value} value={framework}>
        {framework.label}
      </Combobox.Item>
    ))}
  </Combobox>
);

export const WithCustomFilterFn = () => (
  <Combobox
    placeholder="Select favorite frameworks"
    displayValue={(framework: Framework) => framework.label}
    filterFn={(value, search) => (value.charAt(0) === search.charAt(0) ? 1 : 0)}
  >
    {frameworks.map((framework) => (
      <Combobox.Item key={framework.value} value={framework}>
        {framework.label}
      </Combobox.Item>
    ))}
  </Combobox>
);

export const WithControlledFiltering = () => {
  const [search, setSearch] = useState('');

  const filteredPeople =
    search === ''
      ? people
      : people.filter(
          (person) =>
            person.id
              .toString()
              .includes(search.toLowerCase().replace(/\s+/g, '')) ||
            person.name
              .toLowerCase()
              .replace(/\s+/g, '')
              .includes(search.toLowerCase().replace(/\s+/g, ''))
        );

  return (
    <Combobox
      placeholder="Select a person"
      displayValue={(person: Person) => person.name}
      shouldFilter={false}
      search={search}
      onSearchChange={(newSearch) => setSearch(newSearch)}
    >
      {filteredPeople.map((person) => (
        <Combobox.Item key={person.id} value={person}>
          {person.name}
        </Combobox.Item>
      ))}
    </Combobox>
  );
};

export const WithControlledOpenState = () => (
  <Combobox
    placeholder="Select favorite framework"
    displayValue={(framework: Framework) => framework.label}
    open
  >
    {frameworks.map((framework) => (
      <Combobox.Item key={framework.value} value={framework}>
        {framework.label}
      </Combobox.Item>
    ))}
  </Combobox>
);

export const WithDefaultValue = () => (
  <Combobox
    placeholder="Select favorite framework"
    displayValue={(framework: Framework) => framework.label}
    defaultValue={frameworks[0]}
  >
    {frameworks.map((framework) => (
      <Combobox.Item key={framework.value} value={framework}>
        {framework.label}
      </Combobox.Item>
    ))}
  </Combobox>
);

export const WithControlledValue = () => {
  const [value, setValue] = useState<Framework | null>(frameworks[0] ?? null);

  return (
    <>
      <Combobox
        value={value}
        onValueChange={setValue}
        placeholder="Select favorite framework"
        displayValue={(framework: Framework) => framework.label}
      >
        {frameworks.map((framework) => (
          <Combobox.Item key={framework.value} value={framework}>
            {framework.label}
          </Combobox.Item>
        ))}
      </Combobox>
      <pre>{JSON.stringify(value, null, 2)}</pre>
    </>
  );
};

export const WithinForm = () => {
  const [search, setSearch] = useState('');

  const filteredPeople =
    search === ''
      ? people
      : people.filter(
          (person) =>
            person.id
              .toString()
              .includes(search.toLowerCase().replace(/\s+/g, '')) ||
            person.name
              .toLowerCase()
              .replace(/\s+/g, '')
              .includes(search.toLowerCase().replace(/\s+/g, ''))
        );

  return (
    <FormControl>
      <FormLabel>Share with</FormLabel>
      <Combobox
        placeholder="Select a person"
        displayValue={(person: Person) => person.name}
        shouldFilter={false}
        search={search}
        onSearchChange={(val) => setSearch(val)}
        multiple
      >
        {filteredPeople.map((person) => (
          <Combobox.Item key={person.id} value={person}>
            {person.name}
          </Combobox.Item>
        ))}
      </Combobox>
      <FormHelperText>You can search by name or id</FormHelperText>
    </FormControl>
  );
};

DatePicker

I tried to do something similar to the Vercel date picker (https://vercel.com/dashboard/usage)

https://user-images.githubusercontent.com/79363260/235977865-ebd82491-d0b8-416b-9549-7f7ea0f26645.mp4

Implementation

interface DateTimeInputProps {
  type: 'date' | 'time';
  date: Date | undefined;
  onDateChange: (date: Date) => void;
}

const DateTimeInput = ({ type, date, onDateChange }: DateTimeInputProps) => {
  const [value, setValue] = useState<string>('');

  const [isValid, setIsValid] = useState(true);

  useEffect(() => {
    if (!date) {
      setValue('');
      return;
    }
    setValue(
      type === 'date' ? format(date, 'LLL d, y') : format(date, 'p OOO')
    );
    setIsValid(true);
  }, [date, type]);

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { value: newValue } = e.target;
    if (!newValue) {
      if (!date) {
        setValue('');
      } else {
        setValue(
          type === 'date' ? format(date, 'LLL d, y') : format(date, 'p OOO')
        );
      }
      return;
    }
    setValue(newValue);
  };

  const handleInputBlur = () => {
    if (!value) {
      return;
    }
    const parsedDate = new Date(
      type === 'date'
        ? value
        : `${format(date || new Date(), 'LLL d, y')} ${value}`
    );
    if (Number.isNaN(parsedDate.getTime())) {
      setIsValid(false);
      return;
    }
    setIsValid(true);
    onDateChange(parsedDate);
  };

  const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      e.currentTarget.blur();
    }
  };

  return (
    <Input
      size="sm"
      isInvalid={!isValid}
      placeholder={type === 'date' ? 'Date' : 'Time'}
      value={value}
      onChange={handleInputChange}
      onBlur={handleInputBlur}
      onKeyDown={handleInputKeyDown}
    />
  );
};

interface DatePickerCommonProps {
  type?: 'date' | 'datetime';
  withPresets?: boolean;
}

type DatePickerDateProps =
  | {
      date?: Date;
      onDateChange?: (date: Date) => void;
      mode?: 'single';
      defaultDate?: Date;
    }
  | {
      date?: DateRange;
      onDateChange?: (date: DateRange) => void;
      mode?: 'range';
      defaultDate?: DateRange;
    };

export type DatePickerProps = DatePickerCommonProps & DatePickerDateProps;

const isSingleDate = (
  _date: Date | DateRange | undefined,
  mode: 'single' | 'range'
): _date is Date | undefined => mode === 'single';

export const DatePicker = ({
  date: dateProp,
  defaultDate,
  onDateChange,
  mode = 'single',
  type = 'date',
  withPresets = false,
}: DatePickerProps) => {
  const [selectedDate, setSelectedDate] = useControllableState({
    prop: dateProp,
    defaultProp: defaultDate,
    onChange: (state) => {
      onDateChange?.(state as unknown as Date & DateRange);
    },
  });

  // Preserve time of the selected date
  const preserveSelectedTime = (date: Date | DateRange | undefined) => {
    if (!date) {
      return undefined;
    }
    if (!selectedDate) {
      return date;
    }
    if (isSingleDate(selectedDate, mode)) {
      if (selectedDate) {
        (date as Date).setMinutes(selectedDate.getMinutes());
        (date as Date).setHours(selectedDate.getHours());
      }
      return date;
    }
    if (selectedDate.from) {
      (date as DateRange).from?.setMinutes(selectedDate.from.getMinutes());
      (date as DateRange).from?.setHours(selectedDate.from.getHours());
    }
    if (selectedDate.to) {
      (date as DateRange).to?.setMinutes(selectedDate.to.getMinutes());
      (date as DateRange).to?.setHours(selectedDate.to.getHours());
    }
    return date;
  };

  const handlePresetSelect = (value: string) => {
    const date = addDays(new Date(), parseInt(value, 10));
    if (isSingleDate(selectedDate, mode)) {
      setSelectedDate(date);
      return;
    }
    if (selectedDate?.from) {
      setSelectedDate({ from: selectedDate.from, to: date });
      return;
    }
    setSelectedDate({ from: date, to: undefined });
  };

  const renderValue = () => {
    if (isSingleDate(selectedDate, mode)) {
      if (selectedDate) {
        return format(
          selectedDate,
          type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
        );
      }
      return 'Pick a date';
    }
    if (selectedDate?.from) {
      if (selectedDate.to) {
        return `${format(
          selectedDate.from,
          type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
        )} - ${format(
          selectedDate.to,
          type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
        )}`;
      }
      return format(
        selectedDate.from,
        type === 'date' ? 'LLL dd, y' : 'LLL dd, y p'
      );
    }
    return 'Pick a date range';
  };

  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button
          variant="outline"
          className={cn(
            'w-[300px] justify-start text-left font-normal',
            !selectedDate && 'text-base-700'
          )}
          leftIcon={<CalendarIcon className="h-5 w-5" />}
        >
          {renderValue()}
        </Button>
      </Popover.Trigger>
      <Popover.Content className="flex w-min flex-col space-y-2 p-2">
        {withPresets && (
          <Select onValueChange={handlePresetSelect}>
            <Select.Trigger>
              <Select.Value placeholder="Presets" />
            </Select.Trigger>
            <Select.Content position="popper">
              <Select.Item value="0">Today</Select.Item>
              <Select.Item value="1">Tomorrow</Select.Item>
              <Select.Item value="3">In 3 days</Select.Item>
              <Select.Item value="7">In a week</Select.Item>
            </Select.Content>
          </Select>
        )}
        {mode === 'single' ? (
          <div
            className={cn(
              type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
            )}
          >
            <DateTimeInput
              type="date"
              date={selectedDate as Date}
              onDateChange={(date) =>
                setSelectedDate(preserveSelectedTime(date))
              }
            />
            {type === 'datetime' && (
              <DateTimeInput
                type="time"
                date={selectedDate as Date}
                onDateChange={setSelectedDate}
              />
            )}
          </div>
        ) : (
          <div
            className={cn(
              'flex gap-2',
              type === 'datetime' ? 'flex-col' : 'items-center'
            )}
          >
            <div className="space-y-2">
              <Label>Start</Label>
              <div
                className={cn(
                  type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
                )}
              >
                <DateTimeInput
                  type="date"
                  date={(selectedDate as DateRange)?.from}
                  onDateChange={(date) =>
                    setSelectedDate(
                      preserveSelectedTime({
                        ...(selectedDate as DateRange),
                        from: date,
                      }) as DateRange
                    )
                  }
                />
                {type === 'datetime' && (
                  <DateTimeInput
                    type="time"
                    date={(selectedDate as DateRange)?.from}
                    onDateChange={(date) =>
                      setSelectedDate({
                        ...(selectedDate as DateRange),
                        from: date,
                      })
                    }
                  />
                )}
              </div>
            </div>
            <div className="space-y-2">
              <Label>End</Label>
              <div
                className={cn(
                  type === 'datetime' && 'grid grid-cols-[.45fr,.55fr] gap-2'
                )}
              >
                <DateTimeInput
                  type="date"
                  date={(selectedDate as DateRange)?.to}
                  onDateChange={(date) =>
                    setSelectedDate(
                      preserveSelectedTime({
                        ...(selectedDate as DateRange),
                        to: date,
                      })
                    )
                  }
                />
                {type === 'datetime' && (
                  <DateTimeInput
                    type="time"
                    date={(selectedDate as DateRange)?.to}
                    onDateChange={(date) =>
                      setSelectedDate({
                        ...(selectedDate as DateRange),
                        to: date,
                      })
                    }
                  />
                )}
              </div>
            </div>
          </div>
        )}
        <div className="rounded-lg border">
          <Calendar
            mode={mode as unknown as 'single' & 'range'}
            selected={selectedDate}
            onSelect={(date: Date | DateRange | undefined) =>
              setSelectedDate(preserveSelectedTime(date))
            }
          />
        </div>
      </Popover.Content>
    </Popover>
  );
};

Stories

const Template: Story<DatePickerProps> = (args) => <DatePicker {...args} />;

export const Default = Template.bind({});
Default.args = { ...defaultProps };

export const Range = Template.bind({});
Range.args = { ...defaultProps, mode: 'range' };

export const DateTime = Template.bind({});
DateTime.args = { ...defaultProps, type: 'datetime' };

export const DateTimeRange = Template.bind({});
DateTimeRange.args = { ...defaultProps, mode: 'range', type: 'datetime' };

its-monotype avatar May 15 '23 18:05 its-monotype

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

cirdes avatar May 22 '23 19:05 cirdes

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

evangow avatar Jun 12 '23 15:06 evangow

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

MEddarhri avatar Jun 16 '23 13:06 MEddarhri

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.

Lenghak avatar Jul 06 '23 04:07 Lenghak

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.

mxkaske avatar Jul 07 '23 16:07 mxkaske

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.

Lenghak avatar Jul 08 '23 18:07 Lenghak

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

Semkoo avatar Jul 18 '23 23:07 Semkoo

Hi all 👋

I made an autocomplete field based on @mxkaske component, but mine is not a multi select one.

You can find an exemple here: https://www.armand-salle.fr/post/autocomplete-select-shadcn-ui And the source code here: https://github.com/armandsalle/my-site/blob/main/src/components/autocomplete.tsx

https://github.com/shadcn-ui/ui/assets/28579123/bee6d0a8-0737-4b69-a9b9-a9b31bc3bc5f

armandsalle avatar Jul 30 '23 15:07 armandsalle

Hi all 👋

I made an autocomplete field based on @mxkaske component, but mine is not a multi select one.

You can find an exemple here: https://www.armand-salle.fr/post/autocomplete-select-shadcn-ui And the source code here: https://github.com/armandsalle/my-site/blob/main/src/components/autocomplete.tsx

CleanShot.2023-07-30.at.17.01.24.mp4

This is amazing, what about clearing?

Semkoo avatar Jul 31 '23 14:07 Semkoo

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

armandsalle avatar Jul 31 '23 15:07 armandsalle

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

zmzlois avatar Sep 21 '23 09:09 zmzlois

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.

joestrkr avatar Sep 28 '23 06:09 joestrkr

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.

BrendanC23 avatar Nov 15 '23 16:11 BrendanC23

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

kennethpole6 avatar Dec 29 '23 11:12 kennethpole6

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.

hdadr avatar Jan 06 '24 17:01 hdadr

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!

smohammadhn avatar Mar 15 '24 17:03 smohammadhn

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

ramadanomar avatar Mar 22 '24 18:03 ramadanomar

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

fafshari avatar Apr 04 '24 22:04 fafshari

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

samuelkarani avatar Apr 08 '24 14:04 samuelkarani

+1 for autocomplete on <Input /> components

Exitium-DEV avatar Apr 12 '24 07:04 Exitium-DEV

Here's an example of Combobox Input which is pretty common. (similar to Material UI)

I wanted to contribute this example but is currently blocked by cmdk's breaking changes on Shadcn's Combobox. See relevant issue https://github.com/shadcn-ui/ui/pull/2945.

This example is composed with <Command>, <Popover> and <Input>. The benefit of using <Popover> component as opposed to using CSS absolute positioning is that the Combobox options are automatically kept in view as floating-ui handled it internally.

This only works with cmdk ^1.0.0.

https://github.com/shadcn-ui/ui/assets/40173716/c1134a9c-1a05-4c82-bc59-93632e74d67b

"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { Command as CommandPrimitive } from "cmdk"
import { Check } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandList,
} from "@/registry/new-york/ui/command"
import { Input } from "@/registry/new-york/ui/input"
import { Popover, PopoverContent } from "@/registry/new-york/ui/popover"

const frameworks = [
  {
    value: "next.js",
    label: "Next.js",
  },
  {
    value: "sveltekit",
    label: "SvelteKit",
  },
  {
    value: "nuxt.js",
    label: "Nuxt.js",
  },
  {
    value: "remix",
    label: "Remix",
  },
  {
    value: "astro",
    label: "Astro",
  },
]

export default function ComboboxInput() {
  const [open, setOpen] = React.useState(false)
  const [search, setSearch] = React.useState("")
  const [value, setValue] = React.useState("")

  return (
    <div className="flex items-center">
      <Popover open={open} onOpenChange={setOpen}>
        <Command>
          <PopoverPrimitive.Anchor asChild>
            <CommandPrimitive.Input
              asChild
              value={search}
              onValueChange={setSearch}
              onKeyDown={(e) => setOpen(e.key !== "Escape")}
              onMouseDown={() => setOpen((open) => !!search || !open)}
              onFocus={() => setOpen(true)}
              onBlur={(e) => {
                if (!e.relatedTarget?.hasAttribute("cmdk-list")) {
                  setSearch(
                    value
                      ? frameworks.find(
                          (framework) => framework.value === value
                        )?.label ?? ""
                      : ""
                  )
                }
              }}
            >
              <Input placeholder="Select framework..." className="w-[200px]" />
            </CommandPrimitive.Input>
          </PopoverPrimitive.Anchor>
          {!open && <CommandList aria-hidden="true" className="hidden" />}
          <PopoverContent
            asChild
            onOpenAutoFocus={(e) => e.preventDefault()}
            onInteractOutside={(e) => {
              if (
                e.target instanceof Element &&
                e.target.hasAttribute("cmdk-input")
              ) {
                e.preventDefault()
              }
            }}
            className="w-[--radix-popover-trigger-width] p-0"
          >
            <CommandList>
              <CommandEmpty>No framework found.</CommandEmpty>
              <CommandGroup>
                {frameworks.map((framework) => (
                  <CommandItem
                    key={framework.value}
                    value={framework.value}
                    onMouseDown={(e) => e.preventDefault()}
                    onSelect={(currentValue) => {
                      setValue(currentValue === value ? "" : currentValue)
                      setSearch(
                        currentValue === value
                          ? ""
                          : frameworks.find(
                              (framework) => framework.value === currentValue
                            )?.label ?? ""
                      )
                      setOpen(false)
                    }}
                  >
                    <Check
                      className={cn(
                        "mr-2 h-4 w-4",
                        value === framework.value ? "opacity-100" : "opacity-0"
                      )}
                    />
                    {framework.label}
                  </CommandItem>
                ))}
              </CommandGroup>
            </CommandList>
          </PopoverContent>
        </Command>
      </Popover>
    </div>
  )
}

[!NOTE]
{!open && <CommandList aria-hidden="true" className="hidden" />} is needed because <CommandList> must be inside <Command> component at all times.

The current behaviour of the component is partially based on MUI combobox example.

  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.

junwen-k avatar Apr 14 '24 13:04 junwen-k

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.

masewo avatar Apr 30 '24 13:04 masewo

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

ImamJanjua avatar May 20 '24 17:05 ImamJanjua

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

yaba101 avatar Jun 01 '24 14:06 yaba101

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)

rahulkumarsingh73690 avatar Jul 04 '24 07:07 rahulkumarsingh73690

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.

shadcn avatar Jul 28 '24 23:07 shadcn