DEV Community

Vic Ong
Vic Ong

Posted on

Select Dropdown + Searchbar + Clearable (React & Shadcn)

Shadcn provides a fantastic set of beautiful UI components right out of the box. One of the most commonly used components is a selector. However, the component from shadcn (which is based on Radix UI) lacks certain features, such as search functionality and the ability to clear selected options.

In this guide, I'll be implementing a custom select dropdown component that supports searching and clearing options.

Select Dropdown

Let's start with a list of options:

const options = [
  { value: "apple": label: "Apple" },
  { value: "banana": label: "Banana" },
  { value: "avocado": label: "Avocado" },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

First, I will create a basic dropdown using <Command> and <Popover> that:

  • Displays a list of options
  • Shows a checkmark for the selected option
  • Includes a close button
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

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

export const InputSelect: React.FC<{
  options: SelectOption[];
  value?: string;
  onValueChange?: (v: string) => void;
  className?: string;
  style?: React.CSSProperties;
  children: React.ReactNode;
}> = ({
  options,
  value = "",
  onValueChange,
  className,
  children,
}) => {
  const [selectedValue, setSelectedValue] = React.useState<string>(value);
  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);

  const onOptionSelect = (option: string) => {
    setSelectedValue(option);
    onValueChange?.(option);
    setIsPopoverOpen(false);
  };

  return (
    <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent className={cn("w-auto p-0", className)} align="start">
        <Command>
          <CommandList className="max-h-[unset] overflow-y-hidden">
            <CommandGroup className="max-h-[20rem] min-h-[10rem] overflow-y-auto">
              {options.map((option) => {
                const isSelected = selectedValue === option.value;
                return (
                  <CommandItem
                    key={option.value}
                    onSelect={() => onOptionSelect(option.value)}
                    className="cursor-pointer"
                  >
                    <div
                      className={cn(
                        "mr-1 flex h-4 w-4 items-center justify-center",
                        isSelected ? "text-primary" : "invisible"
                      )}
                    >
                      <CheckIcon className="w-4 h-4" />
                    </div>
                    <span>{option.label}</span>
                  </CommandItem>
                );
              })}
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              <div className="flex items-center justify-between">
                <CommandItem
                  onSelect={() => setIsPopoverOpen(false)}
                  className="justify-center flex-1 max-w-full cursor-pointer"
                >
                  Close
                </CommandItem>
              </div>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
InputSelect.displayName = "InputSelect";
Enter fullscreen mode Exit fullscreen mode

Add Search Functionality

To enhance usability, I'll integrate <CommandInput> for built-in search capabilities and <CommandEmpty> to display a message when no results are found.

export const InputSelect = () => {
  // ...
  return (
    <Popover {...}>
      <PopoverTrigger {...} />
      <PopoverContent {...}>
        <Command>
          <CommandInput placeholder="Search..." />
          <CommandList {...}>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup {...}>
              // ...
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              // ...
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
Enter fullscreen mode Exit fullscreen mode

Adding a Clear Option

I also want to provide a button to clear the selected value when one is chosen.

import { Separator } from "@/components/ui/separator";

export const InputSelect: React.FC<{ ... }> = ({ ... }) => {
  // ...

  const onClearAllOptions = () => {
    setSelectedValue("");
    onValueChange?.("");
    setIsPopoverOpen(false);
  };

  return (
    <Popover {...}>
      <PopoverTrigger {...} />
      <PopoverContent {...}>
        <Command>
          <CommandInput {...} />
          <CommandList {...}>
            <CommandEmpty {...} />
            <CommandGroup {...}>
              // ...
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              <div className="flex items-center justify-between">
                {selectedValue && (
                  <>
                    <CommandItem
                      onSelect={onClearAllOptions}
                      className="justify-center flex-1 cursor-pointer"
                    >
                      Clear
                    </CommandItem>
                    <Separator
                      orientation="vertical"
                      className="flex h-full mx-2 min-h-6"
                    />
                  </>
                )}
                <CommandItem
                  onSelect={() => setIsPopoverOpen(false)}
                  className="justify-center flex-1 max-w-full cursor-pointer"
                >
                  Close
                </CommandItem>
              </div>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
Enter fullscreen mode Exit fullscreen mode

So far so good, now I have a dropdown looking like so:

input-select-1

Add Dropdown Trigger

Now, for the last step, I can add a trigger to toggle open/close on the dropdown. I could just use a <Button> for that.

import { Separator } from "@/components/ui/separator";

export const InputSelect: React.FC<{ ... }> = ({ ... }) => {
  // ...

  const onClearAllOptions = () => {
    setSelectedValue("");
    onValueChange?.("");
    setIsPopoverOpen(false);
  };

  return (
    <Popover {...}>
      <PopoverTrigger asChild>
        <Button
          onClick={() => setIsPopoverOpen((prev) => !prev)}
          variant="outline"
          type="button"
          className="flex h-11 w-full items-center justify-between p-1 [&_svg]:pointer-events-auto"
        >
          {selectedValue ? (
            <div className="flex items-center justify-between w-full">
              <div className="flex items-center justify-between w-full">
                <div className="flex items-center px-3 text-foreground">
                  {options.find((v) => v.value === selectedValue)?.label}
                </div>
                <div className="flex items-center justify-between">
                  {selectedValue && (
                    <>
                      <X
                        className="mx-1 h-4 cursor-pointer text-muted-foreground"
                        onClick={(e) => {
                          e.stopPropagation();
                          onClearAllOptions();
                        }}
                      />
                      <Separator orientation="vertical" className="flex h-full min-h-6" />
                    </>
                  )}
                  <ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
                </div>
              </div>
              <div className="flex items-center justify-between">
                {selectedValue && (
                  <>
                    <X
                      className={cn(
                        "mx-1 h-4 cursor-pointer text-muted-foreground",
                      )}
                      onClick={(e) => {
                        e.stopPropagation();
                        onClearAllOptions();
                      }}
                    />
                    <Separator orientation="vertical" className="flex h-full min-h-6" />
                  </>
                )}
                <ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
              </div>
            </div>
          ) : (
            <div className="flex items-center justify-between w-full mx-auto">
              <span className="px-3 text-sm text-muted-foreground">{placeholder}</span>
              <ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
            </div>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent {...}>
        // ...
      </PopoverContent>
    </Popover>
  );
};
Enter fullscreen mode Exit fullscreen mode

With these enhancements, I've built a fully functional <InputSelect> component that supports searching and clearing selected options. I can now just use this component anywhere in my app like so:

import * as React from "react";
import { InputSelect } from "path/to/input-select";

export const MyAwesomeComponent = () => {
  const [value, setValue] = React.useState("apple");

  const options = [...];

  return (
    <div>
      <InputSelect
        options={options}
        value={value}
        onValueChange{(v) => setValue(v)}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

input-select-2

Extend customizability (optional)

In React, I can technically pass any children prop to a component so long as it renders as a functional component.

For example:

// By default React accepts children prop as a ReactNode type
export const CompA = ({ children: React.ReactNode }) => (
  <div>{children}</div>
);
export const CompB = () => <CompA>hello</CompA>;

// We can also modify to accept a function!
export const CompA = ({ children: (v: { value: string; }) => React.ReactNode }) => (
  <div>{children({ value: "foo" })}</div>
);
export const CompB = () => <CompA>{(prop) => <div>{prop.value}</div>}</CompA>;
Enter fullscreen mode Exit fullscreen mode

So, for the <InputSelect>, I can extract the InputSelectTrigger out as a separate component to provide additional customizability

export interface InputSelectProvided {
  options: SelectOption[];
  onValueChange?: (v: string) => void;
  selectedValue: string;
  setSelectedValue: React.Dispatch<React.SetStateAction<string>>;
  isPopoverOpen: boolean;
  setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
  onOptionSelect: (v: string) => void;
  onClearAllOptions: () => void;
}

export const InputSelect: React.FC<{
  options: SelectOption[];
  value?: string;
  onValueChange?: (v: string) => void;
  className?: string;
  style?: React.CSSProperties;
  children: (v: InputSelectProvided) => React.ReactNode;
}> = ({
  options,
  value = "",
  onValueChange,
  className,
  children,
  ...restProps
}) => {
  // ...
  return (
    <Popover {...}>
      <PopoverTrigger asChild>
        {children({
          options,
          onValueChange,
          selectedValue,
          setSelectedValue,
          isPopoverOpen,
          setIsPopoverOpen,
          onOptionSelect,
          onClearAllOptions,
        })}
      </PopoverTrigger>
      // ...
    </Popover>
  );
};
InputSelect.displayName = "InputSelect";

export const InputSelectTrigger = React.forwardRef<
  HTMLButtonElement,
  InputSelectProvided & {
    placeholder?: string;
    className?: string;
    children?: (v: SelectOption) => React.ReactNode;
    style?: React.CSSProperties;
  }
>(
  (
    {
      options,
      // onValueChange,
      selectedValue,
      // setSelectedValue,
      // isPopoverOpen,
      setIsPopoverOpen,
      // onOptionSelect,
      onClearAllOptions,
      placeholder = "Select...",
      className,
      style,
      ...restProps,
    },
    ref,
  ) => {
    return (
      <Button
        ref={ref}
        onClick={() => setIsPopoverOpen((prev) => !prev)}
        variant="outline"
        type="button"
        className={cn(
          "flex h-11 w-full items-center justify-between p-1 [&_svg]:pointer-events-auto",
          className,
        )}
        style={style}
        {...restProps}
      >
        {selectedValue ? (
          <div className="flex items-center justify-between w-full">
            <div className="flex items-center px-3 text-foreground">
              {option?.label}
            </div>
            <div className="flex items-center justify-between">
              {selectedValue && clearable && (
                <>
                  <X
                    className={cn(
                      "mx-1 h-4 cursor-pointer text-muted-foreground",
                    )}
                    onClick={(e) => {
                      e.stopPropagation();
                      onClearAllOptions();
                    }}
                  />
                  <Separator orientation="vertical" className="flex h-full min-h-6" />
                </>
              )}
              <ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
            </div>
          </div>
        ) : (
          <div className="flex items-center justify-between w-full mx-auto">
            <span className="px-3 text-sm text-muted-foreground">{placeholder}</span>
            <ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
          </div>
        )}
      </Button>
    );
  },
);
InputSelectTrigger.displayName = "InputSelectTrigger";
Enter fullscreen mode Exit fullscreen mode

Now, I can also pass additional props to the <InputSelectTrigger> when I need it.

import * as React from "react";
import { InputSelect, InputSelectTrigger } from "path/to/input-select";

export const MyAwesomeComponent = () => {
  const [value, setValue] = React.useState("apple");

  const options = [...];

  return (
    <div>
      <InputSelect
        options={options}
        value={value}
        onValueChange{(v) => setValue(v)}
      >
        {(provided) => <InputSelectTrigger {...provided} className="additional-styling" />}
      </InputSelect>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Links

Demo: https://shadcn-components-extend.vercel.app/?component=input-select

Code: https://github.com/Vic-ong/shadcn-components-extend/blob/main/src/components/extend/input-select.tsx

Top comments (3)

Collapse
 
ciphernutz profile image
Ciphernutz

Very Informative, Thanks for sharing.

Collapse
 
pengeszikra profile image
Peter Vivo

I found one problem on demo page: I cant able to open the drop-down with keys even the focus on it.

Collapse
 
vic_ong profile image
Vic Ong

I'm using the <Popover> component from shadcn so when the component is focused, you can open it by pressing "Enter"

Ref: ui.shadcn.com/docs/components/popover