DEV Community

Vic Ong
Vic Ong

Posted on

4 1 1 1 3

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

Jetbrains image

Is Your CI/CD Server a Prime Target for Attack?

57% of organizations have suffered from a security incident related to DevOps toolchain exposures. It makes senseβ€”CI/CD servers have access to source code, a highly valuable asset. Is yours secure? Check out nine practical tips to protect your CI/CD.

Learn more

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

Jetbrains image

Build Secure, Ship Fast

Discover best practices to secure CI/CD without slowing down your pipeline.

Read more

πŸ‘‹ Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someone’s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay