ui icon indicating copy to clipboard operation
ui copied to clipboard

Example: Combobox with create

Open danthegoodman1 opened this issue 1 year ago • 2 comments

https://ui.shadcn.com/docs/components/combobox is great, but having an option for Create: ... either pinned to the top or bottom would be great too!

Behavior like https://react-select.com/home#creatable

This can be done with existing command items. If I have time later I can try to contribute it! glancing at the structure of the docs site I think I understand how I can add it

danthegoodman1 avatar Dec 14 '23 15:12 danthegoodman1

I have created it as you described, maybe it looks similar. You can try this

'use client';

import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea } from './scroll-area';

export type ComboboxOptions = {
  value: string;
  label: string;
};

type Mode = 'single' | 'multiple';

interface ComboboxProps {
  mode?: Mode;
  options: ComboboxOptions[];
  selected: string | string[]; // Updated to handle multiple selections
  className?: string;
  placeholder?: string;
  onChange?: (event: string | string[]) => void; // Updated to handle multiple selections
  onCreate?: (value: string) => void;
}

export function Combobox({
  options,
  selected,
  className,
  placeholder,
  mode = 'single',
  onChange,
  onCreate,
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState<string>('');

  return (
    <div className={cn('block', className)}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            key={'combobox-trigger'}
            type='button'
            variant='outline'
            role='combobox'
            aria-expanded={open}
            className='w-full justify-between'
          >
            {selected && selected.length > 0 ? (
              <div className='relative mr-auto flex flex-grow flex-wrap items-center overflow-hidden'>
                <span>
                  {mode === 'multiple' && Array.isArray(selected)
                    ? selected
                        .map(
                          (selectedValue: string) =>
                            options.find((item) => item.value === selectedValue)
                              ?.label
                        )
                        .join(', ')
                    : mode === 'single' &&
                      options.find((item) => item.value === selected)?.label}
                </span>
              </div>
            ) : (
              placeholder ?? 'Select Item...'
            )}
            <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
          </Button>
        </PopoverTrigger>
        <PopoverContent className='w-72 max-w-sm p-0'>
          <Command
            filter={(value, search) => {
              if (value.includes(search)) return 1;
              return 0;
            }}
            // shouldFilter={true}
          >
            <CommandInput
              placeholder={placeholder ?? 'Cari Item...'}
              value={query}
              onValueChange={(value: string) => setQuery(value)}
            />
            <CommandEmpty
              onClick={() => {
                if (onCreate) {
                  onCreate(query);
                  setQuery('');
                }
              }}
              className='flex cursor-pointer items-center justify-center gap-1 italic'
            >
              <p>Create: </p>
              <p className='block max-w-48 truncate font-semibold text-primary'>
                {query}
              </p>
            </CommandEmpty>
            <ScrollArea>
              <div className='max-h-80'>
                <CommandGroup>
                  {options.map((option) => (
                    <CommandItem
                      key={option.label}
                      value={option.label}
                      onSelect={(currentValue) => {
                        if (onChange) {
                          if (mode === 'multiple' && Array.isArray(selected)) {
                            onChange(
                              selected.includes(option.value)
                                ? selected.filter(
                                    (item) => item !== option.value
                                  )
                                : [...selected, option.value]
                            );
                          } else {
                            onChange(option.value);
                          }
                        }
                      }}
                    >
                      <Check
                        className={cn(
                          'mr-2 h-4 w-4',
                          selected.includes(option.value)
                            ? 'opacity-100'
                            : 'opacity-0'
                        )}
                      />
                      {option.label}
                    </CommandItem>
                  ))}
                </CommandGroup>
              </div>
            </ScrollArea>
          </Command>
        </PopoverContent>
      </Popover>
    </div>
  );
}

and use like this

<Combobox
    mode='single' //single or multiple
    options={yourOptions}
    placeholder='Select option...'
    selected={typeSelected} // string or array
    onChange={(value) => console.log(value)}
    onCreate={(value) => {
         handleCreateOptions(value);
    }}
/>

Sisableng avatar Jan 18 '24 11:01 Sisableng

I would really like this component as well

lakardion avatar Feb 08 '24 14:02 lakardion

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 Mar 02 '24 23:03 shadcn

I have created it as you described, maybe it looks similar. You can try this

'use client';

import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea } from './scroll-area';

export type ComboboxOptions = {
  value: string;
  label: string;
};

type Mode = 'single' | 'multiple';

interface ComboboxProps {
  mode?: Mode;
  options: ComboboxOptions[];
  selected: string | string[]; // Updated to handle multiple selections
  className?: string;
  placeholder?: string;
  onChange?: (event: string | string[]) => void; // Updated to handle multiple selections
  onCreate?: (value: string) => void;
}

export function Combobox({
  options,
  selected,
  className,
  placeholder,
  mode = 'single',
  onChange,
  onCreate,
}: ComboboxProps) {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState<string>('');

  return (
    <div className={cn('block', className)}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            key={'combobox-trigger'}
            type='button'
            variant='outline'
            role='combobox'
            aria-expanded={open}
            className='w-full justify-between'
          >
            {selected && selected.length > 0 ? (
              <div className='relative mr-auto flex flex-grow flex-wrap items-center overflow-hidden'>
                <span>
                  {mode === 'multiple' && Array.isArray(selected)
                    ? selected
                        .map(
                          (selectedValue: string) =>
                            options.find((item) => item.value === selectedValue)
                              ?.label
                        )
                        .join(', ')
                    : mode === 'single' &&
                      options.find((item) => item.value === selected)?.label}
                </span>
              </div>
            ) : (
              placeholder ?? 'Select Item...'
            )}
            <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
          </Button>
        </PopoverTrigger>
        <PopoverContent className='w-72 max-w-sm p-0'>
          <Command
            filter={(value, search) => {
              if (value.includes(search)) return 1;
              return 0;
            }}
            // shouldFilter={true}
          >
            <CommandInput
              placeholder={placeholder ?? 'Cari Item...'}
              value={query}
              onValueChange={(value: string) => setQuery(value)}
            />
            <CommandEmpty
              onClick={() => {
                if (onCreate) {
                  onCreate(query);
                  setQuery('');
                }
              }}
              className='flex cursor-pointer items-center justify-center gap-1 italic'
            >
              <p>Create: </p>
              <p className='block max-w-48 truncate font-semibold text-primary'>
                {query}
              </p>
            </CommandEmpty>
            <ScrollArea>
              <div className='max-h-80'>
                <CommandGroup>
                  {options.map((option) => (
                    <CommandItem
                      key={option.label}
                      value={option.label}
                      onSelect={(currentValue) => {
                        if (onChange) {
                          if (mode === 'multiple' && Array.isArray(selected)) {
                            onChange(
                              selected.includes(option.value)
                                ? selected.filter(
                                    (item) => item !== option.value
                                  )
                                : [...selected, option.value]
                            );
                          } else {
                            onChange(option.value);
                          }
                        }
                      }}
                    >
                      <Check
                        className={cn(
                          'mr-2 h-4 w-4',
                          selected.includes(option.value)
                            ? 'opacity-100'
                            : 'opacity-0'
                        )}
                      />
                      {option.label}
                    </CommandItem>
                  ))}
                </CommandGroup>
              </div>
            </ScrollArea>
          </Command>
        </PopoverContent>
      </Popover>
    </div>
  );
}

and use like this

<Combobox
    mode='single' //single or multiple
    options={yourOptions}
    placeholder='Select option...'
    selected={typeSelected} // string or array
    onChange={(value) => console.log(value)}
    onCreate={(value) => {
         handleCreateOptions(value);
    }}
/>

You should create a Gist with the code you've come up with 😁 or maybe even make a PR to add to the documentation! I think it could be useful for a lot of people (like me, for example) 🔥🔥🔥.

🤓 But be careful, at the time of writing (20/03/2024), you'll need to downgrade the cmdk package to v0.2.0.

DestroyCom avatar Mar 20 '24 18:03 DestroyCom

To avoid downgrade cmdk to v0.2.0 you need to wrap map iteration with CommandList https://github.com/shadcn-ui/ui/issues/2980#issuecomment-2026578564

<CommandGroup>
  <CommandList>
    {options.map((option) => (
      <CommandItem
        key={option.value}
        value={option.value}
        onSelect={(currentValue) => {
          setValue(currentValue === value ? "" : currentValue);
          setOpen(false);
          onSelect(currentValue);
        }}
      >
        <CheckCircledIcon
          className={cn(
            "mr-2 h-4 w-4",
            value === option.value ? "opacity-100" : "opacity-0"
          )}
        />
        {option.label}
      </CommandItem>
    ))}
  </CommandList>
</CommandGroup>

Originally posted by @LogicalOgbonna in https://github.com/shadcn-ui/ui/issues/2980#issuecomment-2026578564

We also have to replace data-[disabled] with data-[disabled:'true'] in CommandItem component to enable items. https://github.com/shadcn-ui/ui/issues/2944#issuecomment-1986982481

const CommandItem = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
  <CommandPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
      className
    )}
    {...props}
  />
));

note the change data-[disabled='true']

Originally posted by @abolajibisiriyu in https://github.com/shadcn-ui/ui/issues/2944#issuecomment-1986982481

chermdev avatar Apr 28 '24 00:04 chermdev