Search

Ctrl + K

Dropdown

This document outlines the steps to create Dropdown component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • Node.js and npm installed on your machine.
  • Tailwind CSS installed in your project.
  • CVA(class-variance-authority) is a utility for managing CSS class names based on various conditions.
  • clsx is a tiny utility for constructing className strings conditionally.
Step 1: Create useClickAway hook
1// use-click-away.ts
2import { RefObject, useEffect } from "react";
3
4const defaultEvents = ["mousedown", "touchstart"];
5type DefaultEventType = "mousedown" | "touchstart";
6
7const useClickAway = (
8  ref: RefObject<HTMLElement | null>,
9  onClickAway: (event: MouseEvent | TouchEvent) => void,
10) => {
11  useEffect(() => {
12    const handler = (event: MouseEvent | TouchEvent) => {
13      const el = ref.current!;
14      if (!el) return;
15      if (el.contains(event.target as HTMLElement)) return;
16      onClickAway(event);
17    };
18
19    for (const eventName of defaultEvents) {
20      document.addEventListener(eventName as DefaultEventType, handler);
21    }
22
23    return () => {
24      for (const eventName of defaultEvents) {
25        document.removeEventListener(eventName as DefaultEventType, handler);
26      }
27    };
28    // eslint-disable-next-line react-hooks/exhaustive-deps
29  }, [ref]);
30};
31
32export default useClickAway;
33
Step 2: Create Dropdown component
1// dropdown.component.tsx
2import clsx from "clsx";
3import useClickAway from "@/hooks/use-click-away";
4import { ChevronDownIcon } from "lucide-react";
5import { useRef, useState } from "react";
6
7export enum PositionType {
8  Top,
9  Bottom,
10}
11
12export interface DropdownProps {
13  value: string | null;
14  placeholder?: string;
15  className?: string;
16  disabled?: boolean;
17  options: Array<{ label: string; value: string }>;
18  onChange: (value: string) => void;
19}
20
21const Dropdown: React.FC<DropdownProps> = ({
22  value,
23  placeholder,
24  className,
25  disabled,
26  options,
27  onChange,
28}) => {
29  const dropdownRef = useRef<HTMLDivElement | null>(null);
30  const [opening, setOpening] = useState<boolean>(false);
31  const [position, setPosition] = useState<PositionType>(PositionType.Top);
32
33  useClickAway(dropdownRef, () => setOpening(false));
34
35  const handleSelect = (value: string) => {
36    setOpening(false);
37    onChange(value);
38  };
39
40  const handleOpen = () => {
41    const { bottom } = dropdownRef.current!.getBoundingClientRect();
42    const distanceToBottom = window.innerHeight - bottom;
43    setPosition(
44      distanceToBottom > 230 ? PositionType.Bottom : PositionType.Top,
45    );
46    setOpening(true);
47  };
48
49  const selectedOption = options.find((item) => item.value === value);
50
51  return (
52    <div
53      ref={dropdownRef}
54      className={clsx("relative h-[2.375rem] w-[12rem]", className)}
55    >
56      <div
57        className={clsx(
58          "flex h-full items-center rounded-md border px-4",
59          {
60            "cursor-not-allowed border-neutral-300 dark:border-neutral-600":
61              disabled,
62            "cursor-pointer border-neutral-400 hover:border-cyan-500":
63              !disabled,
64          },
65          { "border-cyan-500": opening },
66        )}
67        onClick={() => !disabled && handleOpen()}
68      >
69        <p
70          className={clsx(
71            "w-full flex-1 overflow-hidden text-ellipsis whitespace-nowrap pr-2 text-sm",
72            {
73              "text-neutral-400": disabled,
74              "font-medium text-neutral-700 dark:text-neutral-300":
75                !disabled && selectedOption,
76              "text-neutral-400 dark:text-neutral-500":
77                !disabled && !selectedOption,
78            },
79          )}
80        >
81          {selectedOption?.label || placeholder}
82        </p>
83        <ChevronDownIcon
84          className={clsx(
85            "h-4 w-4",
86            disabled
87              ? "text-neutral-500 dark:text-neutral-700"
88              : "text-neutral-700 dark:text-neutral-400",
89          )}
90        />
91      </div>
92      <div
93        className={clsx(
94          "absolute left-0 z-10 max-h-[13rem] min-w-full max-w-[calc(100%+2rem)] overflow-auto rounded-md border border-neutral-200 bg-white py-2 dark:border-neutral-600 dark:bg-neutral-700",
95          {
96            "bottom-[calc(100%+0.5rem)]": position === PositionType.Top,
97            "top-[calc(100%+0.5rem)]": position === PositionType.Bottom,
98          },
99          { hidden: !opening },
100        )}
101      >
102        {options.map((item) => (
103          <p
104            key={item.value}
105            className="cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap px-3 py-1 text-sm font-medium hover:bg-neutral-200 dark:hover:bg-neutral-500"
106            onClick={() => handleSelect(item.value)}
107          >
108            {item.label}
109          </p>
110        ))}
111      </div>
112    </div>
113  );
114};
115
116export default Dropdown;
117