This document outlines the steps to create Dropdown
component styled withTailwind CSS and using some npm dependency libraries.
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.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
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